import XCTest @testable import iPodderX /// The client against a real daemon, because the shapes it decodes are the server's and a /// hand-written fixture would only prove the fixture matches itself. /// /// Point it at the daemon the browser suite uses, in ipodderx-rs: /// /// node -e "require('./tests/ui/global-setup').prepare()" /// node tests/ui/fixtures/serve.js & /// IPX_CONFIG=$ROOT/config/config.toml IPX_DATA_DIR=$ROOT/data ./target/debug/ipx daemon & /// TEST_RUNNER_IPX_SERVER=http://127.0.0.1:8791 TEST_RUNNER_IPX_TOKEN= \ /// xcodebuild test ... final class APITests: XCTestCase { var api: API! override func setUpWithError() throws { let env = ProcessInfo.processInfo.environment let server = env["IPX_SERVER"] ?? "" let token = env["IPX_TOKEN"] ?? "" try XCTSkipIf(server.isEmpty, "set IPX_SERVER to a fixture daemon") ServerSettings.base = try XCTUnwrap(ServerSettings.parse(server)) // The shared token signs in as the admin. It goes in as a cookie rather than on the // query because that is how the client authenticates everything -- ipx reads // HTTPCookieStorage through the same path the web view fills. if !token.isEmpty, let url = ServerSettings.base { let cookie = try XCTUnwrap(HTTPCookie(properties: [ .name: "ipx_token", .value: token, .path: "/", .domain: url.host ?? "127.0.0.1", ])) HTTPCookieStorage.shared.setCookie(cookie) } api = API() } func testSignedIn() async throws { let me = try await api.me() XCTAssertFalse(me.name.isEmpty, "nobody is signed in") } func testFeedsDecode() async throws { let feeds = try await api.feeds() XCTAssertFalse(feeds.isEmpty, "the fixture daemon has five feeds configured") let show = try XCTUnwrap(feeds.first { $0.id == "test-show" }, "no test-show feed") XCTAssertEqual(show.name, "Test Show") XCTAssertEqual(show.entries, 2) XCTAssertGreaterThanOrEqual(show.subscribers, 1) } func testEntriesPageAndDecodeEnclosures() async throws { let page = try await api.entries(feed: "test-show") XCTAssertEqual(page.total, 2) let second = try XCTUnwrap(page.entries.first { $0.guid == "ui-2" }) XCTAssertEqual(second.feedId, "test-show") // The cap is one download per scan, newest first, so the second episode has the file. let file = try XCTUnwrap(second.playable, "the downloaded episode has no playable file") XCTAssertTrue(file.isDownloaded) XCTAssertEqual(file.mime, "audio/mpeg") XCTAssertFalse(file.isVideo) } /// An image enclosure is on disk and is not a thing to hand a player. func testAnImageIsNotPlayable() async throws { let page = try await api.entries(feed: "picture-blog") let enclosure = try XCTUnwrap(page.entries.first?.enclosures.first) XCTAssertTrue(enclosure.isDownloaded) XCTAssertFalse(enclosure.isPlayable, "a downloaded JPEG is not something to play") } /// Note that the daemon is shared and keeps what earlier tests did to it: the event test /// asks it to scan, which downloads another episode. So this asks what the filter means /// rather than how many things happen to match it today. func testFilterAndSortAreTheServersWork() async throws { let all = try await api.entries(feed: "test-show", filter: .all) let downloaded = try await api.entries(feed: "test-show", filter: .downloaded) XCTAssertLessThanOrEqual(downloaded.total, all.total) XCTAssertTrue(downloaded.entries.allSatisfy { $0.enclosures.contains(where: \.isDownloaded) }, "the downloaded filter returned an item with no file") let asc = try await api.entries(feed: "test-show", sort: .title, direction: .asc) let desc = try await api.entries(feed: "test-show", sort: .title, direction: .desc) XCTAssertEqual(asc.entries.map(\.guid), desc.entries.map(\.guid).reversed()) } func testSearchMatchesTitles() async throws { let hit = try await api.entries(feed: "test-show", search: "Second") XCTAssertEqual(hit.entries.count, 1) let miss = try await api.entries(feed: "test-show", search: "nothing matches this") XCTAssertEqual(miss.total, 0) } /// Read is per-person state, so writing it and reading it back is the whole contract. func testReadRoundTrips() async throws { let before = try await api.entries(feed: "test-show", filter: .all) let entry = try XCTUnwrap(before.entries.first { $0.guid == "ui-1" }) try await api.setRead(!entry.read, feed: entry.feedId, guid: entry.guid) let after = try await api.entries(feed: "test-show") let again = try XCTUnwrap(after.entries.first { $0.guid == "ui-1" }) XCTAssertEqual(again.read, !entry.read, "the flag did not stick") try await api.setRead(entry.read, feed: entry.feedId, guid: entry.guid) } func testPositionRoundTrips() async throws { try await api.setPosition(secs: 42, duration: 900, feed: "test-show", guid: "ui-2") let page = try await api.entries(feed: "test-show") XCTAssertEqual(page.entries.first { $0.guid == "ui-2" }?.position, 42) try await api.setPosition(secs: 0, duration: nil, feed: "test-show", guid: "ui-2") } /// A 401 has to be legible: it means the session went, and only a sign-in fixes it. func testAnUnauthenticatedCallSaysSoPlainly() async throws { HTTPCookieStorage.shared.removeCookies(since: .distantPast) do { _ = try await api.feeds() XCTFail("a signed-out request should not succeed") } catch API.Failure.notSignedIn { // what we want } catch { XCTFail("expected notSignedIn, got \(error)") } } }