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
import CryptoKit
@testable import Grain
import XCTest
/// Pins what a gallery delete removes from the account's repo.
///
/// Deleting the `social.grain.gallery` record on its own leaves the photos,
/// gallery items, exif records, and comments behind. The bug is invisible
/// from the client (the record does disappear), so it's worth asserting
/// every record that goes with the gallery is deleted with it.
final class GalleryServiceTests: GrainTestCase {
private var recorder: Recorder!
override func tearDown() {
MockURLProtocol.handler = nil
super.tearDown()
}
private static let gallery = "at://did:plc:test/social.grain.gallery/g1"
/// What the account's repo holds, by collection, as `listRecords` pages.
private static let repo: [String: String] = [
"social.grain.gallery.item": #"""
{"records": [
{"uri": "at://did:plc:test/social.grain.gallery.item/i1", "value": {"gallery": "at://did:plc:test/social.grain.gallery/g1", "item": "at://did:plc:test/social.grain.photo/p1"}},
{"uri": "at://did:plc:test/social.grain.gallery.item/i2", "value": {"gallery": "at://did:plc:test/social.grain.gallery/g1", "item": "at://did:plc:test/social.grain.photo/p2"}},
{"uri": "at://did:plc:test/social.grain.gallery.item/i3", "value": {"gallery": "at://did:plc:test/social.grain.gallery/other", "item": "at://did:plc:test/social.grain.photo/p2"}}
]}
"""#,
"social.grain.photo.exif": #"""
{"records": [
{"uri": "at://did:plc:test/social.grain.photo.exif/e1", "value": {"photo": "at://did:plc:test/social.grain.photo/p1"}},
{"uri": "at://did:plc:test/social.grain.photo.exif/e2", "value": {"photo": "at://did:plc:test/social.grain.photo/p2"}}
]}
"""#,
"social.grain.favorite": #"""
{"records": [
{"uri": "at://did:plc:test/social.grain.favorite/f1", "value": {"subject": "at://did:plc:test/social.grain.gallery/g1"}},
{"uri": "at://did:plc:test/social.grain.favorite/f2", "value": {"subject": "at://did:plc:else/social.grain.gallery/x"}}
]}
"""#,
"social.grain.comment": #"""
{"records": [
{"uri": "at://did:plc:test/social.grain.comment/c1", "value": {"subject": "at://did:plc:test/social.grain.gallery/g1", "text": "hi"}}
]}
"""#,
]
private func makeClient(status: Int = 200) -> XRPCClient {
let recorder = Recorder()
self.recorder = recorder
MockURLProtocol.handler = { request in
recorder.record(path: request.url!.path, body: Self.body(of: request))
let collection = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "collection" }?.value
let body = request.url!.path.hasSuffix("listRecords")
? Self.repo[collection ?? ""] ?? #"{"records": []}"#
: "{}"
let response = HTTPURLResponse(
url: request.url!,
statusCode: status,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!
return (Data(body.utf8), response)
}
return XRPCClient(baseURL: URL(string: "https://example.test")!, session: MockURLProtocol.mockSession())
}
private func makeAuth() -> AuthContext {
AuthContext(accessToken: "test-token", dpop: DPoP(privateKey: P256.Signing.PrivateKey()), did: "did:plc:test", pds: URL(string: "https://test.local")!)
}
/// The deletes the one commit carried, as `collection/rkey`.
private func deleted() throws -> [String] {
let index = try XCTUnwrap(recorder.paths.firstIndex { $0.hasSuffix("com.atproto.repo.applyWrites") })
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: recorder.bodies[recorder.bodyIndex(ofPath: index)]) as? [String: Any])
let writes = try XCTUnwrap(json["writes"] as? [[String: Any]])
XCTAssertTrue(writes.allSatisfy { $0["$type"] as? String == "com.atproto.repo.applyWrites#delete" })
return writes.map { "\($0["collection"] as? String ?? "")/\($0["rkey"] as? String ?? "")" }
}
// MARK: - Tests
/// The exif of the photos it alone places, its items, those photos, the
/// account's favorites and comments on it, then the gallery: children
/// first, in one commit.
func testDeleteRemovesTheGalleryAndWhatGoesWithItInOneCommit() async throws {
let client = makeClient()
try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())
XCTAssertEqual(try deleted(), [
"social.grain.photo.exif/e1",
"social.grain.gallery.item/i1",
"social.grain.gallery.item/i2",
"social.grain.photo/p1",
"social.grain.favorite/f1",
"social.grain.comment/c1",
"social.grain.gallery/g1",
])
XCTAssertFalse(
recorder.paths.contains { $0.hasSuffix("com.atproto.repo.deleteRecord") },
"deleteRecord orphans the gallery's photos, items, exif, and comments"
)
}
/// A photo another gallery also places stays, with its exif.
func testAPhotoAnotherGalleryPlacesIsKept() async throws {
let client = makeClient()
try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())
let going = try deleted()
XCTAssertFalse(going.contains("social.grain.photo/p2"))
XCTAssertFalse(going.contains("social.grain.photo.exif/e2"))
}
/// The deletes are then indexed, so the gallery leaves the feeds at once.
func testTheDeletesAreIndexed() async throws {
let client = makeClient()
try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())
XCTAssertTrue(recorder.paths.last?.hasSuffix("social.grain.sync.indexRecords") ?? false)
}
/// Callers use `try?` and drop the error, so the failure has to at least
/// reach them rather than being swallowed inside the service.
func testDeletePropagatesAServerFailure() async {
let client = makeClient(status: 500)
do {
try await GalleryService.delete(galleryUri: Self.gallery, client: client, auth: makeAuth())
XCTFail("expected the failure to propagate")
} catch {
// expected
}
}
}
/// Records what the mock server was asked for. The handler runs off the main
/// actor, so it needs its own lock.
private final class Recorder: @unchecked Sendable {
private let lock = NSLock()
private var storedPaths: [String] = []
private var storedBodies: [Data] = []
private var bodyIndices: [Int?] = []
func record(path: String, body: Data?) {
lock.lock()
defer { lock.unlock() }
storedPaths.append(path)
if let body {
bodyIndices.append(storedBodies.count)
storedBodies.append(body)
} else {
bodyIndices.append(nil)
}
}
/// Where in `bodies` the body of the request at `index` is.
func bodyIndex(ofPath index: Int) -> Int {
lock.lock()
defer { lock.unlock() }
return bodyIndices[index] ?? 0
}
var paths: [String] {
lock.lock()
defer { lock.unlock() }
return storedPaths
}
var bodies: [Data] {
lock.lock()
defer { lock.unlock() }
return storedBodies
}
}
private extension GalleryServiceTests {
/// `URLProtocol` moves the body into a stream before the handler sees it.
static func body(of request: URLRequest) -> Data? {
if let body = request.httpBody {
return body
}
guard let stream = request.httpBodyStream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 4096
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
defer { buffer.deallocate() }
while stream.hasBytesAvailable {
let read = stream.read(buffer, maxLength: bufferSize)
if read <= 0 {
break
}
data.append(buffer, count: read)
}
return data
}
}