mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Piped answers with "views": -1 and "uploaded": -1 wherever it has no
number, and playlist rows are full of them. Mapped straight through, a
row read "-0B views • 56 years ago" — -1 millisecond is a date in 1969.
Every Piped-to-Video conversion now runs both fields through
PipedSentinel, which reads a negative as an absent value; the epoch
milliseconds the field really is stay milliseconds.
Piped also refuses a write with HTTP 200 and {"error": "..."} — adding a
video it cannot fetch, or removing an index that moved, both come back
that way. Decoding straight into the success shape failed, and the toast
showed a decoding-error string instead of the server's own sentence. All
authenticated writes now read their body through decodeWriteResponse,
which checks the error shape first (a body carrying error may carry
message beside it) and throws APIError.serverError with the message, so
the existing toasts say what actually happened. HTTPClient's status-code
handling is untouched, so no other backend changes behaviour.
218 lines
7.2 KiB
Swift
218 lines
7.2 KiB
Swift
//
|
|
// PipedResponseTests.swift
|
|
// YatteeTests
|
|
//
|
|
// Piped answers with two things the app used to take at face value: `-1`
|
|
// standing in for a number it does not have, and an error body served under
|
|
// HTTP 200. Both have to be read as what they are before they reach a row or
|
|
// a toast.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Yattee
|
|
|
|
// MARK: - Helpers
|
|
|
|
private let pipedBaseURL = URL(string: "https://piped.example.com")!
|
|
|
|
/// Builds one stream row of a Piped playlist page, with whatever the server
|
|
/// claims for the two sentinel-carrying fields.
|
|
private func pipedStreamJSON(
|
|
videoId: String,
|
|
title: String,
|
|
views: Int64?,
|
|
uploaded: Int64?
|
|
) -> String {
|
|
var fields: [String] = [
|
|
"\"url\": \"/watch?v=\(videoId)\"",
|
|
"\"title\": \"\(title)\"",
|
|
"\"uploaderName\": \"Test Channel\"",
|
|
"\"uploaderUrl\": \"/channel/UCtest\"",
|
|
"\"duration\": 120",
|
|
"\"thumbnail\": \"https://piped.example.com/thumb.jpg\""
|
|
]
|
|
if let views {
|
|
fields.append("\"views\": \(views)")
|
|
}
|
|
if let uploaded {
|
|
fields.append("\"uploaded\": \(uploaded)")
|
|
}
|
|
return "{\(fields.joined(separator: ", "))}"
|
|
}
|
|
|
|
/// Builds the first page of `/playlists/{id}`.
|
|
private func pipedPlaylistPageJSON(streams: [String]) -> Data {
|
|
Data("""
|
|
{
|
|
"name": "Watch Later",
|
|
"uploader": "arek",
|
|
"uploaderUrl": "/channel/UCowner",
|
|
"videos": \(streams.count),
|
|
"relatedStreams": [\(streams.joined(separator: ",\n"))],
|
|
"nextpage": null
|
|
}
|
|
""".utf8)
|
|
}
|
|
|
|
// MARK: - Sentinels
|
|
|
|
@Suite("Piped Sentinel Values")
|
|
struct PipedSentinelTests {
|
|
|
|
@Test("A negative count is no count at all")
|
|
func negativeCountIsNil() {
|
|
#expect(PipedSentinel.count(-1) == nil)
|
|
#expect(PipedSentinel.count(-999) == nil)
|
|
}
|
|
|
|
@Test("A count the server has is carried through, zero included")
|
|
func realCountsSurvive() {
|
|
#expect(PipedSentinel.count(0) == 0)
|
|
#expect(PipedSentinel.count(1_234_567) == 1_234_567)
|
|
}
|
|
|
|
@Test("A missing count stays missing")
|
|
func absentCountIsNil() {
|
|
#expect(PipedSentinel.count(nil) == nil)
|
|
}
|
|
|
|
@Test("A negative upload timestamp is not a date in 1969")
|
|
func negativeUploadedIsNil() {
|
|
#expect(PipedSentinel.uploadedDate(-1) == nil)
|
|
}
|
|
|
|
@Test("An upload timestamp is read as epoch milliseconds")
|
|
func uploadedIsMilliseconds() {
|
|
// 2021-01-01T00:00:00Z in milliseconds.
|
|
let date = PipedSentinel.uploadedDate(1_609_459_200_000)
|
|
#expect(date == Date(timeIntervalSince1970: 1_609_459_200))
|
|
}
|
|
|
|
@Test("A missing upload timestamp stays missing")
|
|
func absentUploadedIsNil() {
|
|
#expect(PipedSentinel.uploadedDate(nil) == nil)
|
|
}
|
|
|
|
@Test("A playlist row of sentinels comes back with neither views nor a date")
|
|
func playlistRowSentinelsAreDropped() throws {
|
|
let page = pipedPlaylistPageJSON(streams: [
|
|
pipedStreamJSON(videoId: "aaa", title: "Sentinels", views: -1, uploaded: -1)
|
|
])
|
|
|
|
let snapshot = try PipedAPI.userPlaylistSnapshot(
|
|
fromPageJSON: [page],
|
|
playlistID: "playlist-uuid",
|
|
instanceURL: pipedBaseURL
|
|
)
|
|
|
|
let video = try #require(snapshot.entries.first?.video)
|
|
#expect(video.viewCount == nil)
|
|
#expect(video.publishedAt == nil)
|
|
}
|
|
|
|
@Test("A playlist row with real numbers keeps them, milliseconds and all")
|
|
func playlistRowRealValuesSurvive() throws {
|
|
let page = pipedPlaylistPageJSON(streams: [
|
|
pipedStreamJSON(videoId: "bbb", title: "Real", views: 4200, uploaded: 1_609_459_200_000)
|
|
])
|
|
|
|
let snapshot = try PipedAPI.userPlaylistSnapshot(
|
|
fromPageJSON: [page],
|
|
playlistID: "playlist-uuid",
|
|
instanceURL: pipedBaseURL
|
|
)
|
|
|
|
let video = try #require(snapshot.entries.first?.video)
|
|
#expect(video.viewCount == 4200)
|
|
#expect(video.publishedAt == Date(timeIntervalSince1970: 1_609_459_200))
|
|
}
|
|
}
|
|
|
|
// MARK: - Write Responses
|
|
|
|
@Suite("Piped Write Response Decoding")
|
|
struct PipedWriteResponseTests {
|
|
|
|
/// Stands in for the create-playlist success shape, which is declared where
|
|
/// only that one call can see it.
|
|
private struct CreateShape: Decodable {
|
|
let playlistId: String
|
|
}
|
|
|
|
@Test("An error body under HTTP 200 is an error, not a message")
|
|
func errorBodyBecomesServerError() throws {
|
|
let data = Data(#"{"error":"Video not found"}"#.utf8)
|
|
|
|
#expect(throws: APIError.serverError("Video not found")) {
|
|
_ = try PipedAPI.decodeWriteResponse(PipedMessageResponse.self, from: data)
|
|
}
|
|
}
|
|
|
|
@Test("The server's own words reach the toast")
|
|
func errorMessageIsReadable() {
|
|
let data = Data(#"{"error":"Index out of bounds"}"#.utf8)
|
|
|
|
do {
|
|
_ = try PipedAPI.decodeWriteResponse(PipedMessageResponse.self, from: data)
|
|
Issue.record("An error body decoded as a success")
|
|
} catch {
|
|
#expect(error.localizedDescription.contains("Index out of bounds"))
|
|
}
|
|
}
|
|
|
|
@Test("A success body still decodes")
|
|
func successBodyDecodes() throws {
|
|
let data = Data(#"{"message":"ok"}"#.utf8)
|
|
|
|
let response = try PipedAPI.decodeWriteResponse(PipedMessageResponse.self, from: data)
|
|
#expect(response.message == "ok")
|
|
}
|
|
|
|
@Test("An error alongside a message is still an error")
|
|
func errorWinsOverMessage() throws {
|
|
let data = Data(#"{"error":"Video not found","message":"ok"}"#.utf8)
|
|
|
|
#expect(throws: APIError.serverError("Video not found")) {
|
|
_ = try PipedAPI.decodeWriteResponse(PipedMessageResponse.self, from: data)
|
|
}
|
|
}
|
|
|
|
@Test("A blank error is not treated as a failure")
|
|
func blankErrorFallsThroughToTheSuccessShape() throws {
|
|
let data = Data(#"{"error":" ","message":"ok"}"#.utf8)
|
|
|
|
let response = try PipedAPI.decodeWriteResponse(PipedMessageResponse.self, from: data)
|
|
#expect(response.message == "ok")
|
|
}
|
|
|
|
@Test("The same check guards every write shape, not just the message one")
|
|
func createShapeIsGuardedToo() throws {
|
|
let failure = Data(#"{"error":"You are not authorized"}"#.utf8)
|
|
#expect(throws: APIError.serverError("You are not authorized")) {
|
|
_ = try PipedAPI.decodeWriteResponse(CreateShape.self, from: failure)
|
|
}
|
|
|
|
let success = Data(#"{"playlistId":"abc-123"}"#.utf8)
|
|
let response = try PipedAPI.decodeWriteResponse(CreateShape.self, from: success)
|
|
#expect(response.playlistId == "abc-123")
|
|
}
|
|
|
|
@Test("A body that is neither shape is still a decoding failure")
|
|
func unknownBodyIsADecodingError() {
|
|
let data = Data(#"{"unexpected":true}"#.utf8)
|
|
|
|
do {
|
|
_ = try PipedAPI.decodeWriteResponse(PipedMessageResponse.self, from: data)
|
|
Issue.record("An unknown body decoded as a success")
|
|
} catch let error as APIError {
|
|
guard case .decodingError = error else {
|
|
Issue.record("Expected a decoding error, got \(error)")
|
|
return
|
|
}
|
|
} catch {
|
|
Issue.record("Expected an APIError, got \(error)")
|
|
}
|
|
}
|
|
}
|