From 18c070d62897a63f70e74a082365abfbc95dd906 Mon Sep 17 00:00:00 2001 From: Mark Xue Date: Tue, 22 Sep 2026 16:08:23 -0700 Subject: [PATCH] Add relationshipLookup, bskyProfileIfExists, and BskyCDN read helpers Three additive helpers on Atproto.XRPC.BskyAppCallable: relationshipLookup(actor:others:) dedupes/chunks app.bsky.graph.getRelationships at the lexicon's 30-subject limit and reports not-found subjects rather than dropping them (not an account-existence check - the AppView returns a relationship entry for any well-formed DID); bskyProfileIfExists(actor:) maps getProfile's undeclared not-found shape (400 InvalidRequest, "Profile not found") to nil; and Atproto.BskyCDN.imageURL(_:did:blob:host:) builds CDN image URLs from a blob reference with explicit RFC 3986 path-segment encoding so a crafted DID/CID can't escape its segment. Also fixes GetRelationships.Parameters.init to accept exactly 30 others (the lexicon's own maxLength, previously rejected) and makes GetRelationships.Errors public, since it's already thrown by public API. Co-Authored-By: Claude Sonnet 5 --- .changeset/bsky-read-helpers.md | 7 + .../Agent/Agents/BskyAppAgent.swift | 89 +++++ Sources/AtprotoClient/BskyCDN.swift | 111 ++++++ .../Lexicon/AppBsky/Actor/GetProfile.swift | 5 +- .../AppBsky/Graph/GetRelationships.swift | 50 ++- Tests/AtprotoClientTests/BskyCDNTests.swift | 112 ++++++ .../BskyProfileIfExistsTests.swift | 104 ++++++ .../GetRelationshipsTests.swift | 339 ++++++++++++++++++ 8 files changed, 808 insertions(+), 9 deletions(-) create mode 100644 .changeset/bsky-read-helpers.md create mode 100644 Sources/AtprotoClient/BskyCDN.swift create mode 100644 Tests/AtprotoClientTests/BskyCDNTests.swift create mode 100644 Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift create mode 100644 Tests/AtprotoClientTests/GetRelationshipsTests.swift diff --git a/.changeset/bsky-read-helpers.md b/.changeset/bsky-read-helpers.md new file mode 100644 index 0000000..f3a13ea --- /dev/null +++ b/.changeset/bsky-read-helpers.md @@ -0,0 +1,7 @@ +--- +"@germ-network/atprotoclient": minor +--- + +Add three Bluesky read helpers on `Atproto.XRPC.BskyAppCallable`: `relationshipLookup(actor:others:)`, which reports which `app.bsky.graph.getRelationships` subjects the AppView returned a relationship for and which it didn't (unlike `getRelationships(actor:subjects:)`, which drops every not-found subject and loses which ones they were) - note that this isn't an account-existence check, since the AppView returns a relationship entry for any well-formed DID whether or not the account exists; and `bskyProfileIfExists(actor:)`, which maps `app.bsky.actor.getProfile`'s undeclared not-found shape (a 400 `InvalidRequest` whose message is "Profile not found") to `nil` rather than throwing. Also add `Atproto.BskyCDN.imageURL(_:did:blob:host:)` to build Bluesky CDN image URLs from a blob reference. + +Also fix `Lexicon.App.Bsky.Graph.GetRelationships.Parameters.init` to accept exactly 30 `others`, matching the lexicon's `others.maxLength: 30` - it previously rejected exactly 30, and make `Lexicon.App.Bsky.Graph.GetRelationships.Errors` (`tooManyOthersInput`, `actorMismatch`) public - it's already thrown by public API. diff --git a/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift b/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift index 1a1b833..5d7e81d 100644 --- a/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift +++ b/Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift @@ -64,9 +64,33 @@ extension Atproto.XRPC.BskyAppCallable { parameters: .init(actor: actor) ) } + + /// `getProfile` declares no dedicated not-found error - a missing actor + /// arrives as a 400 `InvalidRequest` whose message happens to be "Profile + /// not found". Maps exactly that shape to `nil`; every other error + /// (including other `InvalidRequest` messages, and a deactivated or + /// suspended account's `AccountDeactivated`/`AccountTakedown`) rethrows. + public func bskyProfileIfExists( + actor: LexiconString.AtIdentifier + ) async throws -> Lexicon.App.Bsky.Actor.Defs.ProfileViewDetailed? { + do { + return try await bskyProfile(actor: actor) + } catch Atproto.XRPC.ParseError.xrpcError( + status: .badRequest, + error: let error + ) + where error.error == "InvalidRequest" + && error.message == "Profile not found" + { + return nil + } + } } extension Atproto.XRPC.BskyAppCallable { + /// Drops every not-found subject and loses which ones they were - use + /// ``relationshipLookup(actor:others:)`` to keep that information. Still + /// throws `GetRelationships.Errors.tooManyOthersInput` above 30 subjects. public func getRelationships( actor: Atproto.DID, subjects: [Atproto.DID] @@ -79,6 +103,71 @@ extension Atproto.XRPC.BskyAppCallable { return results.relationships .compactMap { $0.asRelationships } } + + /// `others` deduped (preserving order) and chunked into requests of at + /// most 30, the lexicon's `others.maxLength`, merging every chunk's + /// result. Only `.relationship` + /// entries for a requested DID count as found - an unrequested DID the + /// server threw in is dropped, and `.notFoundActor` is never used to + /// populate `found` (its `actor` can arrive in handle form, which can't + /// be mapped back to the DID that was actually requested). Whatever + /// wasn't found - whether the server said so explicitly (by DID or by + /// handle) or simply omitted it - lands in `notFound`. See + /// `GetRelationships.Lookup`'s doc for why this isn't an + /// account-existence check. + public func relationshipLookup( + actor: Atproto.DID, + others: [Atproto.DID] + ) async throws -> Lexicon.App.Bsky.Graph.GetRelationships.Lookup { + let deduped = Self.dedupedPreservingOrder(others) + guard !deduped.isEmpty else { + return .init() + } + let requested = Set(deduped) + + var found: [Atproto.DID: Lexicon.App.Bsky.Graph.Relationships] = [:] + for chunk in Self.chunked( + deduped, + intoSizeAtMost: Lexicon.App.Bsky.Graph.GetRelationships.Parameters.maxOthers + ) { + let parameters = try Lexicon.App.Bsky.Graph.GetRelationships.Parameters( + actor: .did(actor), + others: chunk.map { .did($0) } + ) + let output = try await call( + Lexicon.App.Bsky.Graph.GetRelationships.self, + parameters: parameters + ) + guard output.actor == actor else { + throw Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch( + requested: actor, + returned: output.actor + ) + } + for entry in output.relationships { + guard case .relationship(let relationship) = entry, + requested.contains(relationship.did) + else { continue } + found[relationship.did] = relationship + } + } + let notFound = deduped.filter { found[$0] == nil } + return .init(found: found, notFound: notFound) + } + + private static func dedupedPreservingOrder(_ dids: [Atproto.DID]) -> [Atproto.DID] { + var seen = Set() + return dids.filter { seen.insert($0).inserted } + } + + private static func chunked( + _ dids: [Atproto.DID], + intoSizeAtMost size: Int + ) -> [[Atproto.DID]] { + stride(from: 0, to: dids.count, by: size).map { + Array(dids[$0.. URL { + guard + let components = URLComponents( + url: host, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + let componentHost = components.host, !componentHost.isEmpty, + components.path.isEmpty || components.path == "/", + components.query == nil, + components.fragment == nil, + components.user == nil, + components.password == nil + else { + throw Errors.invalidHost + } + var built = components + // Normalize the scheme's case so "HTTPS://cdn.bsky.app" and + // "https://cdn.bsky.app/" both produce the same canonical URL as + // "https://cdn.bsky.app". + built.scheme = scheme + // `did.rawValue` is attacker-influenced (a DID's identifier is + // otherwise unvalidated) and only percent-encoding "/" out of it + // stops it from smuggling extra path segments (e.g. + // "did:plc:../../evil") into the CDN request - `URLComponents.path` + // leaves "/" alone since it's a legal path character, so this is + // set through `percentEncodedPath` instead, on an already-escaped + // string. + built.percentEncodedPath = + "/img/\(kind.rawValue)/plain/\(try Self.pathSegment(did.rawValue))/\(try Self.pathSegment(blob.ref.link.string))@jpeg" + guard let url = built.url else { + throw Errors.invalidHost + } + return url + } + + // Built explicitly from RFC 3986 (unreserved + sub-delims + ":", + // excluding "/" and "@") rather than derived from + // `CharacterSet.urlPathAllowed` - Darwin's built-in set and a + // modified copy of it don't necessarily encode `:` the same way, + // which would make exact-URL tests diverge on Linux. Excluding "@" + // keeps the only literal "@" in the path the fixed "@jpeg" suffix. + private static let pathSegmentAllowed: CharacterSet = { + var allowed = CharacterSet() + allowed.insert( + charactersIn: + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + allowed.insert(charactersIn: "!$&'()*+,;=") + allowed.insert(charactersIn: ":") + return allowed + }() + + private static func pathSegment(_ value: String) throws -> String { + guard + let encoded = value.addingPercentEncoding( + withAllowedCharacters: Self.pathSegmentAllowed) + else { + throw Errors.unencodablePathSegment + } + return encoded + } + } +} + +extension Atproto.BskyCDN { + public enum Errors: Error, Equatable, Sendable { + /// `host` isn't a bare `http`/`https` origin (no path, query, + /// fragment, or userinfo). + case invalidHost + /// A DID or CID couldn't be percent-encoded into a path segment. + case unencodablePathSegment + } +} + +extension Atproto.BskyCDN.Errors: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidHost: + "host must be a bare http(s) origin (no path, query, fragment, or userinfo)" + case .unencodablePathSegment: + "a DID or CID could not be percent-encoded into a CDN URL path segment" + } + } +} diff --git a/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift b/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift index daee0a2..83e83b4 100644 --- a/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift +++ b/Sources/AtprotoClient/Lexicon/AppBsky/Actor/GetProfile.swift @@ -41,5 +41,8 @@ extension Lexicon.App.Bsky.Actor { } extension Lexicon.App.Bsky.Actor.GetProfile: Atproto.XRPC.ResponseParsing { - public static var badRequestErrors: Set { defaultErrors } + /// Explicit about `InvalidRequest` rather than relying on it already + /// being in `defaultErrors` - `bskyProfileIfExists(actor:)` depends on + /// this error being parsed rather than falling through to `.unrecognized`. + public static var badRequestErrors: Set { defaultErrors.union(["InvalidRequest"]) } } diff --git a/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift b/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift index 32c39dc..7366c81 100644 --- a/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift +++ b/Sources/AtprotoClient/Lexicon/AppBsky/Graph/GetRelationships.swift @@ -33,7 +33,9 @@ extension Lexicon.App.Bsky.Graph { others: [LexiconString.AtIdentifier]? ) throws { if let others { - guard others.count < Self.maxOthers else { + // The lexicon declares others.maxLength: 30, so exactly 30 + // is valid - only 31+ is too many. + guard others.count <= Self.maxOthers else { throw Errors.tooManyOthersInput } } @@ -104,16 +106,48 @@ extension Lexicon.App.Bsky.Graph { } } - enum Errors: LocalizedError { - case tooManyOthersInput + /// Found/not-found subjects from ``Atproto.XRPC.BskyAppCallable/relationshipLookup(actor:others:)``, + /// which - unlike ``Atproto.XRPC.BskyAppCallable/getRelationships(actor:subjects:)`` - + /// keeps track of which requested subjects weren't found. + /// + /// `found`/`notFound` isn't an account-existence check: the AppView + /// returns a `#relationship` entry for any well-formed DID, whether or + /// not the account exists, so a nonexistent `did:plc` still lands in + /// `found`. `notFound` only covers what the server explicitly reported + /// via `notFoundActor` (in practice, for a handle that doesn't + /// resolve) or omitted outright. Callers that need to know whether an + /// account actually exists should use + /// ``Atproto.XRPC.BskyAppCallable/bskyProfileIfExists(actor:)``. + public struct Lookup: Sendable { + public var found: [Atproto.DID: Relationships] + public var notFound: [Atproto.DID] - var errorDescription: String? { - switch self { - case .tooManyOthersInput: - "Too many others input" - } + public init( + found: [Atproto.DID: Relationships] = [:], + notFound: [Atproto.DID] = [] + ) { + self.found = found + self.notFound = notFound } } + + public enum Errors: Error, Equatable, Sendable { + case tooManyOthersInput + /// A chunked request's response named a different `actor` than the + /// one requested. + case actorMismatch(requested: Atproto.DID, returned: Atproto.DID) + } + } +} + +extension Lexicon.App.Bsky.Graph.GetRelationships.Errors: LocalizedError { + public var errorDescription: String? { + switch self { + case .tooManyOthersInput: + "Too many others input" + case .actorMismatch(let requested, let returned): + "getRelationships returned actor \(returned.rawValue), requested \(requested.rawValue)" + } } } diff --git a/Tests/AtprotoClientTests/BskyCDNTests.swift b/Tests/AtprotoClientTests/BskyCDNTests.swift new file mode 100644 index 0000000..d3212c8 --- /dev/null +++ b/Tests/AtprotoClientTests/BskyCDNTests.swift @@ -0,0 +1,112 @@ +// +// BskyCDNTests.swift +// AtprotoClientTests +// + +import AtprotoTypes +import Foundation +import Testing + +@testable import AtprotoClient + +@Suite struct BskyCDNTests { + private func blob(cid: Atproto.CID) -> Atproto.Primitive.Blob { + .init(ref: .init(link: cid), mimeType: "image/jpeg", size: 100) + } + + @Test("imageURL builds the exact avatar CDN URL") + func avatarURLExactString() throws { + let did = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL(.avatar, did: did, blob: blob(cid: cid)) + + #expect( + url.absoluteString + == "https://cdn.bsky.app/img/avatar/plain/\(did.rawValue)/\(cid.string)@jpeg" + ) + } + + @Test("imageURL builds the exact banner CDN URL against a custom host") + func bannerURLExactString() throws { + let did = Atproto.DID(method: .web, identifier: "example.com") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL( + .banner, did: did, blob: blob(cid: cid), + host: URL(string: "https://cdn.example.net")!) + + #expect( + url.absoluteString + == "https://cdn.example.net/img/banner/plain/\(did.rawValue)/\(cid.string)@jpeg" + ) + } + + @Test("A crafted DID with path-traversal segments can't escape the CDN path") + func pathTraversalDIDStaysOneSegment() throws { + let did = try Atproto.DID(string: "did:plc:../../../../evil/x?y#z") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL(.avatar, did: did, blob: blob(cid: cid)) + + #expect(url.host == "cdn.bsky.app") + #expect(url.absoluteString.hasPrefix("https://cdn.bsky.app/img/avatar/plain/")) + // The DID's own "/" characters must be encoded, not literal path + // separators - otherwise the ".." segments could traverse out of + // /img/avatar/plain/. + #expect(!url.absoluteString.contains("/..")) + #expect(url.absoluteString.contains("%2F")) + } + + @Test( + "A trailing slash and an uppercase scheme are both accepted, producing the canonical URL", + arguments: ["https://cdn.bsky.app/", "HTTPS://cdn.bsky.app"] + ) + func acceptsTrailingSlashAndUppercaseScheme(_ hostString: String) throws { + let did = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL( + .avatar, did: did, blob: blob(cid: cid), + host: URL(string: hostString)!) + + #expect( + url.absoluteString + == "https://cdn.bsky.app/img/avatar/plain/\(did.rawValue)/\(cid.string)@jpeg" + ) + } + + @Test("An '@' in a DID is percent-encoded, leaving only the '@jpeg' suffix literal") + func atSignInDIDIsEncoded() throws { + let did = Atproto.DID(method: .plc, identifier: "evil@example.com") + let cid = try Atproto.CID(string: "baaaaaaaa") + + let url = try Atproto.BskyCDN.imageURL(.avatar, did: did, blob: blob(cid: cid)) + + #expect(did.rawValue.contains("@")) + let pathAfterDID = url.absoluteString.components(separatedBy: "/").last ?? "" + #expect(pathAfterDID == "\(cid.string)@jpeg") + #expect(url.absoluteString.contains("%40")) + } + + @Test( + "imageURL rejects a host that isn't a bare http(s) origin", + arguments: [ + "https://cdn.bsky.app/extra", + "https://cdn.bsky.app?x=1", + "https://cdn.bsky.app#frag", + "https://user@cdn.bsky.app", + "ftp://cdn.bsky.app", + ] + ) + func rejectsNonBareHost(_ hostString: String) throws { + let did = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let cid = try Atproto.CID(string: "baaaaaaaa") + + #expect(throws: Atproto.BskyCDN.Errors.invalidHost) { + try Atproto.BskyCDN.imageURL( + .avatar, did: did, blob: blob(cid: cid), + host: URL(string: hostString)!) + } + } +} diff --git a/Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift b/Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift new file mode 100644 index 0000000..0381114 --- /dev/null +++ b/Tests/AtprotoClientTests/BskyProfileIfExistsTests.swift @@ -0,0 +1,104 @@ +// +// BskyProfileIfExistsTests.swift +// AtprotoClientTests +// + +import AtprotoTypes +import Foundation +import GermConvenience +import GermConvenienceHTTP +import HTTPTypes +import Testing + +import struct AtprotoClientMocks.StubHTTPFetcher + +@testable import AtprotoClient + +@Suite struct BskyProfileIfExistsTests { + private let appViewURL = URL(string: "https://appview.example.com")! + private let actor: LexiconString.AtIdentifier + + init() throws { + actor = .handle(try Atproto.Handle(string: "nobody.example.com")) + } + + private func agent(fetcher: HTTPFetcher) throws -> BskyAppViewAgent { + try BskyAppViewAgent(serviceUrl: appViewURL, resourceFetcher: fetcher) + } + + /// `getProfile` declares no errors of its own - confirms the live + /// AppView's actual miss shape parses as a typed `.xrpcError` (rather + /// than falling through to `.unrecognized`) with the message intact, + /// which is what `bskyProfileIfExists` pattern-matches against. + @Test( + "GetProfile.badRequestErrors recognizes InvalidRequest, so the real parser produces .xrpcError" + ) + func realParserProducesXrpcErrorWithMessageIntact() async throws { + #expect( + Lexicon.App.Bsky.Actor.GetProfile.badRequestErrors.contains( + "InvalidRequest")) + + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"error\":\"InvalidRequest\",\"message\":\"Profile not found\"}" + .utf8), + response: .init(status: .badRequest))) + + let thrown = await #expect(throws: Atproto.XRPC.ParseError.self) { + try await self.agent(fetcher: fetcher).bskyProfile(actor: actor) + } + guard case .xrpcError(let status, let error) = thrown else { + Issue.record("expected .xrpcError, got \(String(describing: thrown))") + return + } + #expect(status == .badRequest) + #expect(error.error == "InvalidRequest") + #expect(error.message == "Profile not found") + } + + @Test("The exact not-found shape (400 InvalidRequest 'Profile not found') maps to nil") + func exactNotFoundShapeMapsToNil() async throws { + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"error\":\"InvalidRequest\",\"message\":\"Profile not found\"}" + .utf8), + response: .init(status: .badRequest))) + + let profile = try await agent(fetcher: fetcher).bskyProfileIfExists(actor: actor) + + #expect(profile == nil) + } + + @Test("A 400 InvalidRequest with a different message rethrows rather than mapping to nil") + func differentMessageRethrows() async throws { + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"error\":\"InvalidRequest\",\"message\":\"Something else\"}" + .utf8), + response: .init(status: .badRequest))) + + await #expect(throws: Atproto.XRPC.ParseError.self) { + try await self.agent(fetcher: fetcher).bskyProfileIfExists( + actor: self.actor) + } + } + + @Test("A 200 response returns the profile") + func successReturnsProfile() async throws { + let fetcher = StubHTTPFetcher( + .init( + data: Data( + """ + {"did":"did:plc:aaaaaaaaaaaaaaaaaaaaaaaa","handle":"alice.example.com","displayName":"Alice"} + """.utf8), + response: .init(status: .ok))) + + let profile = try await agent(fetcher: fetcher).bskyProfileIfExists(actor: actor) + + #expect(profile?.handle.rawValue == "alice.example.com") + #expect(profile?.displayName == "Alice") + } +} diff --git a/Tests/AtprotoClientTests/GetRelationshipsTests.swift b/Tests/AtprotoClientTests/GetRelationshipsTests.swift new file mode 100644 index 0000000..c128989 --- /dev/null +++ b/Tests/AtprotoClientTests/GetRelationshipsTests.swift @@ -0,0 +1,339 @@ +// +// GetRelationshipsTests.swift +// AtprotoClientTests +// + +import AtprotoTypes +import Foundation +import GermConvenience +import GermConvenienceHTTP +import HTTPTypes +import Testing + +import struct AtprotoClientMocks.StubHTTPFetcher + +@testable import AtprotoClient + +/// Thread-safe call counter - `StubHTTPFetcher`'s handler is synchronous but +/// invoked from concurrent async call sites. +private final class CallCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + @discardableResult + func increment() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return value + } +} + +private func xrpcURL( + _ serviceUrl: URL, + nsid: String, + queryItems: [URLQueryItem] +) -> URL { + var components = URLComponents(url: serviceUrl, resolvingAgainstBaseURL: false)! + components.path = "/xrpc/\(nsid)" + components.queryItems = queryItems + return components.url! +} + +@Suite struct GetRelationshipsParametersTests { + private static let maxOthers = Lexicon.App.Bsky.Graph.GetRelationships.Parameters.maxOthers + + @Test("Exactly maxOthers (30) is accepted - the lexicon's others.maxLength") + func exactlyMaxOthersIsAccepted() throws { + let others = (0.. BskyAppViewAgent { + try BskyAppViewAgent(serviceUrl: appViewURL, resourceFetcher: fetcher) + } + + @Test("relationshipLookup splits found vs. not-found subjects") + func splitsFoundAndNotFound() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let foundDID = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let notFoundDID = Atproto.DID(method: .plc, identifier: "cccccccccccccccccccccccc") + let requestURL = xrpcURL( + appViewURL, nsid: "app.bsky.graph.getRelationships", + queryItems: [ + .init(name: "actor", value: actor.rawValue), + .init(name: "others", value: foundDID.rawValue), + .init(name: "others", value: notFoundDID.rawValue), + ]) + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + counter.increment() + #expect(request.request.url == requestURL) + return .init( + data: Data( + """ + {"actor":"\(actor.rawValue)","relationships":[ + {"$type":"app.bsky.graph.defs#relationship","did":"\(foundDID.rawValue)"}, + {"$type":"app.bsky.graph.defs#notFoundActor","actor":"\(notFoundDID.rawValue)","notFound":true} + ]} + """.utf8), + response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher).relationshipLookup( + actor: actor, others: [foundDID, notFoundDID]) + + #expect(result.found[foundDID]?.did == foundDID) + #expect(result.notFound == [notFoundDID]) + #expect(counter.count == 1) + } + + @Test( + "An unrequested subject is dropped, a handle-form not-found and an omitted subject both land in notFound" + ) + func dropsUnrequestedAndReportsHandleFormAndOmitted() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let a = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let b = Atproto.DID(method: .plc, identifier: "cccccccccccccccccccccccc") + let c = Atproto.DID(method: .plc, identifier: "dddddddddddddddddddddddd") + let requestURL = xrpcURL( + appViewURL, nsid: "app.bsky.graph.getRelationships", + queryItems: [ + .init(name: "actor", value: actor.rawValue), + .init(name: "others", value: a.rawValue), + .init(name: "others", value: b.rawValue), + .init(name: "others", value: c.rawValue), + ]) + // b reported not-found by handle; c omitted entirely; an unrequested + // DID is returned alongside the legitimately-found a. + let fetcher = StubHTTPFetcher { request in + #expect(request.request.url == requestURL) + return .init( + data: Data( + """ + {"actor":"\(actor.rawValue)","relationships":[ + {"$type":"app.bsky.graph.defs#relationship","did":"\(a.rawValue)"}, + {"$type":"app.bsky.graph.defs#notFoundActor","actor":"bob.example.com","notFound":true}, + {"$type":"app.bsky.graph.defs#relationship","did":"did:plc:zzzzzzzzzzzzzzzzzzzzzzzz"} + ]} + """.utf8), + response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: [a, b, c]) + + #expect(Set(result.found.keys) == [a]) + #expect(Set(result.notFound) == [b, c]) + } + + @Test("Duplicate subjects are sent once and reported once, whether found or not") + func dedupesSubjects() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let foundDID = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let notFoundDID = Atproto.DID(method: .plc, identifier: "cccccccccccccccccccccccc") + let requestURL = xrpcURL( + appViewURL, nsid: "app.bsky.graph.getRelationships", + queryItems: [ + .init(name: "actor", value: actor.rawValue), + .init(name: "others", value: foundDID.rawValue), + .init(name: "others", value: notFoundDID.rawValue), + ]) + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + counter.increment() + // Exactly one `others` query item per subject - a request built + // from undeduped input would carry each of them repeatedly and + // fail to match. + #expect(request.request.url == requestURL) + // notFoundDID is omitted entirely - it's requested three times + // below but must be reported as not-found exactly once, not once + // per duplicate. + return .init( + data: Data( + """ + {"actor":"\(actor.rawValue)","relationships":[ + {"$type":"app.bsky.graph.defs#relationship","did":"\(foundDID.rawValue)"} + ]} + """.utf8), + response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher) + .relationshipLookup( + actor: actor, + others: [ + foundDID, foundDID, notFoundDID, notFoundDID, notFoundDID, + ]) + + #expect(counter.count == 1) + #expect(result.found.count == 1) + #expect(result.notFound == [notFoundDID]) + } + + @Test("A mismatched actor in the response throws actorMismatch") + func mismatchedActorThrows() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let wrongActor = Atproto.DID(method: .plc, identifier: "ffffffffffffffffffffffff") + let other = Atproto.DID(method: .plc, identifier: "bbbbbbbbbbbbbbbbbbbbbbbb") + let fetcher = StubHTTPFetcher( + .init( + data: Data( + "{\"actor\":\"\(wrongActor.rawValue)\",\"relationships\":[]}" + .utf8), + response: .init(status: .ok))) + + await #expect( + throws: Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch( + requested: actor, returned: wrongActor) + ) { + try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: [other]) + } + } + + @Test("A mismatched actor on a later chunk (not just the first) throws actorMismatch") + func mismatchedActorOnLaterChunkThrows() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let wrongActor = Atproto.DID(method: .plc, identifier: "ffffffffffffffffffffffff") + // 31 subjects -> 2 chunks; the first chunk's response names the + // correct actor, so a check that only looks at the first chunk would + // miss the mismatch on the second. + let subjects = (0..<31).map { + Atproto.DID(method: .plc, identifier: "subject\($0)") + } + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + let requestNumber = counter.increment() + let respondingActor = requestNumber == 1 ? actor : wrongActor + return .init( + data: Data( + "{\"actor\":\"\(respondingActor.rawValue)\",\"relationships\":[]}" + .utf8), + response: .init(status: .ok)) + } + + await #expect( + throws: Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch( + requested: actor, returned: wrongActor) + ) { + try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: subjects) + } + #expect(counter.count == 2) + } + + @Test("Empty others returns an empty result without any request") + func emptyOthersMakesNoRequest() async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let counter = CallCounter() + let fetcher = StubHTTPFetcher { request in + counter.increment() + Issue.record("expected no request for empty others") + return .init(data: Data(), response: .init(status: .ok)) + } + + let result = try await agent(fetcher: fetcher) + .relationshipLookup(actor: actor, others: []) + + #expect(result.found.isEmpty) + #expect(result.notFound.isEmpty) + #expect(counter.count == 0) + } + + /// Thread-safe collector for each request's `others` DIDs, in call order. + private final class ChunkCollector: @unchecked Sendable { + private let lock = NSLock() + private var chunks: [[String]] = [] + + func append(_ chunk: [String]) { + lock.lock() + defer { lock.unlock() } + chunks.append(chunk) + } + + var all: [[String]] { + lock.lock() + defer { lock.unlock() } + return chunks + } + } + + @Test( + "others are chunked at maxOthers (30 -> 1 request, 31 -> 2, 60 -> 2, 61 -> 3), each request carries at most 30, and the chunks concatenate back to the original order", + arguments: [(30, 1), (31, 2), (60, 2), (61, 3)] + ) + func chunksAtMaxOthers(_ subjectsAndRequests: (subjects: Int, requests: Int)) async throws { + let actor = Atproto.DID(method: .plc, identifier: "aaaaaaaaaaaaaaaaaaaaaaaa") + let subjects = (0..