1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
@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)
}
}