Files
ipodderx-app/ios/Tests/APITests.swift
Ray Slakinski 14170fe5aa Send the filter names ipx actually understands (#6)
The Pinned tab was not filtering. It sent filter=pinned, and Filter::parse
in db.rs knows unread, downloaded, flagged and in_progress and falls
through to All for anything else -- so the tab returned every item and
looked like it had worked. The column is still named flagged, for what it
was before the interface called it pinned, and the page had this right all
along.

Currently Listening was worse in kind. ipx has filter=in_progress for
exactly it: started past the first few seconds, short of the 90% the UI
calls finished, measured against the length this person's player reported
where there is one. The client asked for everything and trimmed the fifty
rows it happened to receive, so the view showed whichever started episodes
were near the top of the library, left out the rest, and counted wrong.

Both came of writing the filter names from the interface's words instead of
reading what the server parses.

The tests now assert what each filter means rather than how many rows it
returns -- every unread row unread, every downloaded row with a file, every
pinned row pinned, every in-progress row started -- because the failure
here was a full page of entirely plausible rows, which no count would have
caught. Pinned also has to match fewer than everything, which is the shape
the bug took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:56:28 -04:00

158 lines
7.6 KiB
Swift

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=<fixture 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())
}
/// Every filter, checked against what it means rather than against a count. The bug this
/// replaces was a filter name the server did not know: it fell through to All and returned
/// a full page of plausible rows, which no count-based assertion would have noticed.
func testEachFilterReturnsOnlyWhatItMeans() async throws {
let all = try await api.entries(filter: .all, limit: 200)
let unread = try await api.entries(filter: .unread, limit: 200)
XCTAssertTrue(unread.entries.allSatisfy { !$0.read }, "unread returned a read item")
let downloaded = try await api.entries(filter: .downloaded, limit: 200)
XCTAssertTrue(downloaded.entries.allSatisfy { $0.enclosures.contains(where: \.isDownloaded) },
"downloaded returned an item with no file")
let pinned = try await api.entries(filter: .pinned, limit: 200)
XCTAssertTrue(pinned.entries.allSatisfy(\.flagged), "pinned returned an unpinned item")
XCTAssertLessThan(pinned.total, all.total,
"pinned matched everything, which is what a filter name the server "
+ "does not understand looks like")
let started = try await api.entries(filter: .inProgress, limit: 200)
XCTAssertTrue(started.entries.allSatisfy { $0.position > 0 },
"in_progress returned an item nobody has started")
}
/// The raw values are the wire format, and getting one wrong fails silently.
func testFilterNamesAreTheOnesTheServerKnows() {
XCTAssertEqual(IPX.Filter.pinned.rawValue, "flagged")
XCTAssertEqual(IPX.Filter.inProgress.rawValue, "in_progress")
XCTAssertEqual(IPX.Filter.tabs.map(\.rawValue), ["all", "unread", "downloaded", "flagged"])
}
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)")
}
}
}