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..