~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
353 lines · 14.0 KB Raw History
@testable import Grain
import XCTest

/// Captures the record the cross-post actually writes.
private final class PostLog: @unchecked Sendable {
    private let lock = NSLock()
    private var requests: [URLRequest] = []

    func record(_ request: URLRequest) {
        lock.withLock { requests.append(request) }
    }

    var count: Int {
        lock.withLock { requests.count }
    }

    private var last: URLRequest? {
        lock.withLock { requests.last }
    }

    var lastPath: String? {
        last?.url?.path
    }

    var lastBody: [String: Any]? {
        guard let request = last else { return nil }
        var data = request.httpBody
        if data == nil, let stream = request.httpBodyStream {
            stream.open()
            defer { stream.close() }
            var collected = Data()
            let size = 8192
            let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
            defer { buffer.deallocate() }
            while stream.hasBytesAvailable {
                let read = stream.read(buffer, maxLength: size)
                guard read > 0 else { break }
                collected.append(buffer, count: read)
            }
            data = collected
        }
        guard let data else { return nil }
        return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
    }

    var lastRecord: [String: Any]? {
        lastBody?["record"] as? [String: Any]
    }
}

/// The record a gallery's Bluesky cross-post writes. Its shape has to match the
/// web client's byte for byte — a wrong `$type` or a facet whose byte offsets
/// drift shows up as a mangled post on someone's timeline, not as an error.
///
/// `buildPostText` already has its own suite; this covers what happens to that
/// text afterwards: facets, the image embed, and which write verb is used.
@MainActor
final class BlueskyCrossPostTests: GrainTestCase {
    private var client: XRPCClient!
    private var log: PostLog!
    private var auth: AuthContext!
    private var did = ""

    override func setUp() async throws {
        try await super.setUp()
        did = "did:plc:bsky-\(UUID().uuidString)"
        client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
        auth = try AuthContext(accessToken: "token", dpop: DPoP.loadOrCreate(for: did), did: did, pds: URL(string: "https://test.local")!)
        log = PostLog()

        let log = log!
        MockURLProtocol.handler = { request in
            log.record(request)
            let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
            return (Data(#"{"uri": "at://did:plc:test/app.bsky.feed.post/1", "cid": "bafy"}"#.utf8), response)
        }
    }

    override func tearDown() async throws {
        MockURLProtocol.handler = nil
        try? DPoP.clearKey(for: did)
        try await super.tearDown()
    }

    private func blob(_ link: String = "bafyblob") -> BlobRef {
        BlobRef(type: "blob", ref: BlobRef.BlobLink(link: link), mimeType: "image/jpeg", size: 1234)
    }

    private func options(
        title: String? = "Golden hour",
        description: String? = nil,
        location: (name: String, address: [String: AnyCodable]?)? = nil,
        images: [(blob: BlobRef, alt: String, width: Int, height: Int)] = []
    ) -> BlueskyPostOptions {
        BlueskyPostOptions(
            url: "https://grain.social/profile/did:plc:test/gallery/1",
            title: title,
            location: location,
            description: description,
            images: images
        )
    }

    private func post(
        _ options: BlueskyPostOptions,
        rkey: String? = nil
    ) async throws {
        try await BlueskyPost.create(
            options: options,
            client: client,
            repo: "did:plc:test",
            auth: auth,
            rkey: rkey,
            createdAt: "2025-01-02T00:00:00.000Z"
        )
    }

    // MARK: - Record shape

    func testTheRecordCarriesTheTextAndTheGrainTag() async throws {
        try await post(options())

        let record = try XCTUnwrap(log.lastRecord)
        XCTAssertEqual(record["tags"] as? [String], ["grainsocial"])
        XCTAssertEqual(record["createdAt"] as? String, "2025-01-02T00:00:00.000Z")
        let text = try XCTUnwrap(record["text"] as? String)
        XCTAssertTrue(text.contains("Golden hour"))
        XCTAssertTrue(text.contains("https://grain.social/profile/did:plc:test/gallery/1"))
    }

    /// The cross-post is written under a record key the draft assigned, so a
    /// resumed publish overwrites its own post rather than posting twice.
    func testAKeyedCrossPostOverwritesRatherThanPostsAgain() async throws {
        try await post(options(), rkey: "3jzfcijpj2z2a")

        XCTAssertEqual(log.lastPath, "/xrpc/com.atproto.repo.putRecord")
        XCTAssertEqual(log.lastBody?["rkey"] as? String, "3jzfcijpj2z2a")
        XCTAssertEqual(log.lastBody?["collection"] as? String, "app.bsky.feed.post")
        XCTAssertEqual(log.lastBody?["repo"] as? String, "did:plc:test")
    }

    func testAnUnkeyedCrossPostCreatesANewRecord() async throws {
        try await post(options())

        XCTAssertEqual(log.lastPath, "/xrpc/com.atproto.repo.createRecord")
        XCTAssertEqual(log.lastBody?["collection"] as? String, "app.bsky.feed.post")
    }

    // MARK: - Image embed

    func testImagesBecomeABlueskyImageEmbed() async throws {
        try await post(options(images: [
            (blob: blob("bafyone"), alt: "First photo", width: 3000, height: 2000),
        ]))

        let record = try XCTUnwrap(log.lastRecord)
        let embed = try XCTUnwrap(record["embed"] as? [String: Any])
        XCTAssertEqual(embed["$type"] as? String, "app.bsky.embed.images")

        let images = try XCTUnwrap(embed["images"] as? [[String: Any]])
        XCTAssertEqual(images.count, 1)
        XCTAssertEqual(images[0]["alt"] as? String, "First photo")

        let image = try XCTUnwrap(images[0]["image"] as? [String: Any])
        XCTAssertEqual(image["$type"] as? String, "blob")
        XCTAssertEqual(image["mimeType"] as? String, "image/jpeg")
        XCTAssertEqual(image["size"] as? Int, 1234)
        XCTAssertEqual((image["ref"] as? [String: Any])?["$link"] as? String, "bafyone")

        let ratio = try XCTUnwrap(images[0]["aspectRatio"] as? [String: Any])
        XCTAssertEqual(ratio["width"] as? Int, 3000)
        XCTAssertEqual(ratio["height"] as? Int, 2000)
    }

    /// Bluesky takes at most four images; sending more is rejected outright.
    func testOnlyTheFirstFourImagesAreEmbedded() async throws {
        let images = (0 ..< 7).map {
            (blob: blob("bafy\($0)"), alt: "Photo \($0)", width: 100, height: 100)
        }

        try await post(options(images: images))

        let embed = try XCTUnwrap(log.lastRecord?["embed"] as? [String: Any])
        let embedded = try XCTUnwrap(embed["images"] as? [[String: Any]])
        XCTAssertEqual(embedded.count, 4)
        XCTAssertEqual(embedded.first?["alt"] as? String, "Photo 0")
        XCTAssertEqual(embedded.last?["alt"] as? String, "Photo 3")
    }

    /// A gallery with no blobs yet must not send an empty embed — Bluesky
    /// rejects one.
    func testAPostWithNoImagesHasNoEmbed() async throws {
        try await post(options())

        XCTAssertNil(log.lastRecord?["embed"])
    }

    /// A blob missing its optional fields still has to produce a well-formed
    /// embed rather than nulls the server won't take.
    func testAnIncompleteBlobFallsBackToDefaults() async throws {
        let bare = BlobRef(type: nil, ref: nil, mimeType: nil, size: nil)

        try await post(options(images: [(blob: bare, alt: "", width: 1, height: 1)]))

        let embed = try XCTUnwrap(log.lastRecord?["embed"] as? [String: Any])
        let image = try XCTUnwrap((embed["images"] as? [[String: Any]])?.first?["image"] as? [String: Any])
        XCTAssertEqual(image["$type"] as? String, "blob")
        XCTAssertEqual(image["mimeType"] as? String, "image/jpeg")
        XCTAssertEqual(image["size"] as? Int, 0)
        XCTAssertEqual((image["ref"] as? [String: Any])?["$link"] as? String, "")
    }

    // MARK: - Facets in the written record

    /// The gallery link is always in the text, so every cross-post carries at
    /// least one link facet — without it the URL posts as plain text.
    func testTheGalleryLinkIsFacetedAsALink() async throws {
        try await post(options())

        let facets = try XCTUnwrap(log.lastRecord?["facets"] as? [[String: Any]])
        let features = facets.flatMap { ($0["features"] as? [[String: Any]]) ?? [] }
        XCTAssertTrue(features.contains { $0["$type"] as? String == "app.bsky.richtext.facet#link" })
        XCTAssertTrue(features.contains {
            $0["uri"] as? String == "https://grain.social/profile/did:plc:test/gallery/1"
        })
    }

    func testAHashtagInTheDescriptionIsFacetedAsATag() async throws {
        try await post(options(description: "Shot on #Portra400 in the morning."))

        let facets = try XCTUnwrap(log.lastRecord?["facets"] as? [[String: Any]])
        let features = facets.flatMap { ($0["features"] as? [[String: Any]]) ?? [] }
        let tags = features.filter { $0["$type"] as? String == "app.bsky.richtext.facet#tag" }
        XCTAssertTrue(tags.contains { $0["tag"] as? String == "Portra400" })
    }

    /// Every facet's byte range has to land inside the text it annotates, or
    /// Bluesky renders the link over the wrong characters.
    func testEveryFacetPointsAtRealBytesOfTheText() async throws {
        try await post(options(title: "A café gallery", description: "Shot on #Portra400 ☕️"))

        let record = try XCTUnwrap(log.lastRecord)
        let text = try XCTUnwrap(record["text"] as? String)
        let byteCount = text.utf8.count
        let facets = try XCTUnwrap(record["facets"] as? [[String: Any]])

        for facet in facets {
            let index = try XCTUnwrap(facet["index"] as? [String: Any])
            let start = try XCTUnwrap(index["byteStart"] as? Int)
            let end = try XCTUnwrap(index["byteEnd"] as? Int)
            XCTAssertGreaterThanOrEqual(start, 0)
            XCTAssertGreaterThan(end, start)
            XCTAssertLessThanOrEqual(end, byteCount, "A facet runs past the end of the text")
        }
    }

    // MARK: - parseTextToFacets

    func testAPlainSentenceHasNoFacets() async {
        let facets = await BlueskyPost.parseTextToFacets("Just a caption with nothing special in it.")
        XCTAssertTrue(facets.isEmpty)
    }

    func testEmptyTextHasNoFacets() async {
        let facets = await BlueskyPost.parseTextToFacets("")
        XCTAssertTrue(facets.isEmpty)
    }

    func testAURLIsFacetedWithItsOwnByteRange() async throws {
        let text = "See https://grain.social/x for more"
        let facets = await BlueskyPost.parseTextToFacets(text)

        XCTAssertEqual(facets.count, 1)
        let facet = try XCTUnwrap(facets.first)
        let bytes = Array(text.utf8)[facet.index.byteStart ..< facet.index.byteEnd]
        XCTAssertEqual(String(bytes: bytes, encoding: .utf8), "https://grain.social/x")
        guard case let .link(uri) = facet.features.first else {
            return XCTFail("Expected a link feature")
        }
        XCTAssertEqual(uri, "https://grain.social/x")
    }

    func testHashtagsAreFacetedWithoutTheHash() async throws {
        let text = "Filed under #portra400 and #kodak"
        let facets = await BlueskyPost.parseTextToFacets(text)

        let tags: [String] = facets.compactMap { facet in
            if case let .tag(tag) = facet.features.first {
                return tag
            }
            return nil
        }
        XCTAssertEqual(tags, ["portra400", "kodak"])

        // The byte range covers the "#" too, so the whole token is highlighted.
        let first = try XCTUnwrap(facets.first)
        let bytes = Array(text.utf8)[first.index.byteStart ..< first.index.byteEnd]
        XCTAssertEqual(String(bytes: bytes, encoding: .utf8), "#portra400")
    }

    /// The pattern is `#\p{L}…`, so a tag has to start with a letter. That rules
    /// out the film-stock tags this app is full of — `#35mm`, `#120` — which
    /// cross-post as plain text rather than as links. Pinned because it matches
    /// `RichTextView`'s in-app parsing, not because a photography app obviously
    /// wants it.
    func testATagStartingWithADigitIsNotFaceted() async {
        let facets = await BlueskyPost.parseTextToFacets("Shot on #35mm and #120")

        let tags = facets.filter {
            if case .tag = $0.features.first {
                true
            } else {
                false
            }
        }
        XCTAssertTrue(tags.isEmpty)
    }

    /// Emoji ahead of a facet push the byte offsets away from the character
    /// offsets, which is exactly where an offset bug shows up.
    func testByteOffsetsSurviveEmojiEarlierInTheText() async throws {
        let text = "📷☕️ shot at https://grain.social/x"
        let facets = await BlueskyPost.parseTextToFacets(text)

        let facet = try XCTUnwrap(facets.first)
        let bytes = Array(text.utf8)[facet.index.byteStart ..< facet.index.byteEnd]
        XCTAssertEqual(String(bytes: bytes, encoding: .utf8), "https://grain.social/x")
        XCTAssertGreaterThan(facet.index.byteStart, text.distance(from: text.startIndex, to: text.startIndex))
    }

    /// URLs are claimed before hashtags, so a fragment inside a link must not
    /// also be parsed as a tag.
    func testAFragmentInsideALinkIsNotAlsoAHashtag() async {
        let facets = await BlueskyPost.parseTextToFacets("See https://grain.social/x#section for more")

        let tags = facets.filter {
            if case .tag = $0.features.first {
                true
            } else {
                false
            }
        }
        XCTAssertTrue(tags.isEmpty, "The link claimed those bytes first")
    }

    func testFacetsComeBackInTextOrder() async {
        let facets = await BlueskyPost.parseTextToFacets("#first then https://grain.social/x then #last")

        let starts = facets.map(\.index.byteStart)
        XCTAssertEqual(starts, starts.sorted())
        XCTAssertEqual(facets.count, 3)
    }
}