A library for handling DID identifiers used in Bluesky AT Protocol
1require 'time'
2
3describe DIDKit::PLCOperation do
4 subject { described_class }
5
6 let(:base_json) { load_did_json('bnewbold_log.json').last }
7
8 describe '#initialize' do
9 context 'with a valid plc operation' do
10 let(:json) { base_json }
11
12 it 'should return a PLCOperation with parsed data' do
13 op = subject.new(json)
14
15 op.json.should == json
16 op.type.should == :plc_operation
17 op.did.should == 'did:plc:44ybard66vv44zksje25o7dz'
18 op.cid.should == 'bafyreiaoaelqu32ngmqd2mt3v3zvek7k34cvo7lvmk3kseuuaag5eptg5m'
19 op.created_at.should be_a(Time)
20 op.created_at.should == Time.parse("2025-06-06T00:34:40.824Z")
21 op.handles.should == ['bnewbold.net']
22 op.services.map(&:key).should == ['atproto_pds']
23 end
24 end
25
26 context 'when argument is not a hash' do
27 let(:json) { [base_json] }
28
29 it 'should raise a format error' do
30 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
31 end
32 end
33
34 context 'when did is missing' do
35 let(:json) { base_json.tap { |h| h.delete('did') }}
36
37 it 'should raise a format error' do
38 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
39 end
40 end
41
42 context 'when did is not a string' do
43 let(:json) { base_json.merge('did' => 123) }
44
45 it 'should raise a format error' do
46 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
47 end
48 end
49
50 context "when did doesn't start with did:" do
51 let(:json) { base_json.merge('did' => 'foobar') }
52
53 it 'should raise a format error' do
54 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
55 end
56 end
57
58 context 'when cid is missing' do
59 let(:json) { base_json.tap { |h| h.delete('cid') }}
60
61 it 'should raise a format error' do
62 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
63 end
64 end
65
66 context 'when cid is not a string' do
67 let(:json) { base_json.merge('cid' => 700) }
68
69 it 'should raise a format error' do
70 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
71 end
72 end
73
74 context 'when createdAt is missing' do
75 let(:json) { base_json.tap { |h| h.delete('createdAt') }}
76
77 it 'should raise a format error' do
78 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
79 end
80 end
81
82 context 'when createdAt is invalid' do
83 let(:json) { base_json.merge('createdAt' => 123) }
84
85 it 'should raise a format error' do
86 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
87 end
88 end
89
90 context 'when operation block is missing' do
91 let(:json) { base_json.tap { |h| h.delete('operation') }}
92
93 it 'should raise a format error' do
94 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
95 end
96 end
97
98 context 'when operation block is not a hash' do
99 let(:json) { base_json.merge('operation' => 'invalid') }
100
101 it 'should raise a format error' do
102 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
103 end
104 end
105
106 context 'when operation type is missing' do
107 let(:json) { base_json.tap { |h| h['operation'].delete('type') }}
108
109 it 'should raise a format error' do
110 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
111 end
112 end
113
114 context 'when operation type is not a string' do
115 let(:json) { base_json.tap { |h| h['operation']['type'] = 5 }}
116
117 it 'should raise a format error' do
118 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
119 end
120 end
121
122 context 'when operation type is not plc_operation' do
123 let(:json) { base_json.tap { |h| h['operation']['type'] = 'other' }}
124
125 it 'should not raise an error' do
126 expect { subject.new(json) }.not_to raise_error
127 end
128
129 it 'should return the operation type' do
130 op = subject.new(json)
131 op.type.should == :other
132 end
133
134 it 'should not try to parse services' do
135 json['services'] = nil
136
137 expect { subject.new(json) }.not_to raise_error
138 end
139
140 it 'should return nil from services' do
141 op = subject.new(json)
142 op.services.should be_nil
143 end
144
145 it 'should not try to parse handles' do
146 json['alsoKnownAs'] = nil
147
148 expect { subject.new(json) }.not_to raise_error
149 end
150
151 it 'should return nil from handles' do
152 op = subject.new(json)
153 op.handles.should be_nil
154 end
155 end
156
157 context 'when alsoKnownAs is not an array' do
158 let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = 'at://dholms.xyz' }}
159
160 it 'should raise a format error' do
161 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
162 end
163 end
164
165 context 'when alsoKnownAs elements are not strings' do
166 let(:json) { base_json.tap { |h| h['operation']['alsoKnownAs'] = [666] }}
167
168 it 'should raise a format error' do
169 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
170 end
171 end
172
173 context 'when alsoKnownAs contains multiple handles' do
174 let(:json) {
175 base_json.tap { |h|
176 h['operation']['alsoKnownAs'] = [
177 'at://dholms.xyz',
178 'https://example.com',
179 'at://other.handle'
180 ]
181 }
182 }
183
184 it 'should pick those starting with at:// and remove the prefixes' do
185 op = subject.new(json)
186 op.handles.should == ['dholms.xyz', 'other.handle']
187 end
188
189 it 'should return all entries in #also_known_as' do
190 op = subject.new(json)
191 op.also_known_as.should == ['at://dholms.xyz', 'https://example.com', 'at://other.handle']
192 end
193 end
194
195 context 'when services are missing' do
196 let(:json) { base_json.tap { |h| h['operation'].delete('services') }}
197
198 it 'should raise a format error' do
199 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
200 end
201 end
202
203 context 'when services entry is not a hash' do
204 let(:json) {
205 base_json.tap { |h|
206 h['operation']['services'] = [
207 {
208 "id": "#atproto_pds",
209 "type": "AtprotoPersonalDataServer",
210 "serviceEndpoint": "https://pds.dholms.xyz"
211 }
212 ]
213 }
214 }
215
216 it 'should raise a format error' do
217 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
218 end
219 end
220
221 context 'when a service entry type is missing' do
222 let(:json) {
223 base_json.tap { |h|
224 h['operation']['services'] = {
225 "atproto_pds" => { "endpoint" => "https://pds.dholms.xyz" }
226 }
227 }
228 }
229
230 it 'should raise a format error' do
231 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
232 end
233 end
234
235 context 'when a service entry type is not a string' do
236 let(:json) {
237 base_json.tap { |h|
238 h['operation']['services'] = {
239 "atproto_pds" => { "type" => 77, "endpoint" => "https://pds.dholms.xyz" }
240 }
241 }
242 }
243
244 it 'should raise a format error' do
245 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
246 end
247 end
248
249 context 'when a service entry endpoint is missing' do
250 let(:json) {
251 base_json.tap { |h|
252 h['operation']['services'] = {
253 "atproto_pds" => { "type" => "AtprotoPersonalDataServer" }
254 }
255 }
256 }
257
258 it 'should raise a format error' do
259 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
260 end
261 end
262
263 context 'when a service entry endpoint is not a string' do
264 let(:json) {
265 base_json.tap { |h|
266 h['operation']['services'] = {
267 "atproto_pds" => { "type" => "AtprotoPersonalDataServer", "endpoint" => { :host => 'localhost' }}
268 }
269 }
270 }
271
272 it 'should raise a format error' do
273 expect { subject.new(json) }.to raise_error(DIDKit::FormatError)
274 end
275 end
276
277 context 'when a service entry endpoint is not an URI' do
278 let(:json) {
279 base_json.tap { |h|
280 h['operation']['services'] = {
281 "atproto_pds" => { "type" => "AtprotoPersonalDataServer", "endpoint" => "2 + 2" }
282 }
283 }
284 }
285
286 it 'should *not* raise a format error' do
287 expect { subject.new(json) }.to_not raise_error
288 end
289 end
290
291 context 'when services are valid' do
292 let(:json) {
293 base_json.tap { |h|
294 h['operation']['services'] = {
295 "atproto_pds" => {
296 "type" => "AtprotoPersonalDataServer",
297 "endpoint" => "https://pds.dholms.xyz"
298 },
299 "atproto_labeler" => {
300 "type" => "AtprotoLabeler",
301 "endpoint" => "https://labeler.example.com"
302 },
303 "custom_service" => {
304 "type" => "OtherService",
305 "endpoint" => "https://custom.example.com"
306 }
307 }
308 }
309 }
310
311 it 'should parse services into ServiceRecords' do
312 op = subject.new(json)
313
314 op.services.length.should == 3
315 op.services.each { |s| s.should be_a(DIDKit::ServiceRecord) }
316
317 pds, labeller, custom = op.services
318
319 pds.type.should == 'AtprotoPersonalDataServer'
320 pds.endpoint.should == 'https://pds.dholms.xyz'
321
322 labeller.type.should == 'AtprotoLabeler'
323 labeller.endpoint.should == 'https://labeler.example.com'
324
325 custom.type.should == 'OtherService'
326 custom.endpoint.should == 'https://custom.example.com'
327 end
328
329 it 'should allow fetching services by key + type' do
330 op = subject.new(json)
331
332 custom = op.get_service('custom_service', 'OtherService')
333 custom.should be_a(DIDKit::ServiceRecord)
334 custom.endpoint.should == 'https://custom.example.com'
335 end
336
337 describe '#pds_endpoint' do
338 it 'should return the endpoint of #atproto_pds' do
339 op = subject.new(json)
340 op.pds_endpoint.should == 'https://pds.dholms.xyz'
341 end
342 end
343
344 describe '#pds_host' do
345 it 'should return the host part of #atproto_pds endpoint' do
346 op = subject.new(json)
347 op.pds_host.should == 'pds.dholms.xyz'
348 end
349 end
350
351 describe '#labeler_endpoint' do
352 it 'should return the endpoint of #atproto_labeler' do
353 op = subject.new(json)
354 op.labeler_endpoint.should == 'https://labeler.example.com'
355 end
356 end
357
358 describe '#labeler_host' do
359 it 'should return the host part of #atproto_labeler endpoint' do
360 op = subject.new(json)
361 op.labeler_host.should == 'labeler.example.com'
362 end
363 end
364
365 it 'should expose the "labeller" aliases for endpoint and host' do
366 op = subject.new(json)
367
368 op.labeller_endpoint.should == 'https://labeler.example.com'
369 op.labeller_host.should == 'labeler.example.com'
370 end
371 end
372
373 context 'when services are valid but the specific ones are missing' do
374 let(:json) {
375 base_json.tap { |h|
376 h['operation']['services'] = {
377 "custom_service" => {
378 "type" => "CustomService",
379 "endpoint" => "https://custom.example.com"
380 }
381 }
382 }
383 }
384
385 it 'should parse service records' do
386 op = subject.new(json)
387 op.services.length.should == 1
388 end
389
390 describe '#get_service' do
391 it 'should return nil' do
392 op = subject.new(json)
393 other = op.get_service('other_service', 'OtherService')
394 other.should be_nil
395 end
396 end
397
398 describe '#pds_endpoint' do
399 it 'should return nil' do
400 op = subject.new(json)
401 op.pds_endpoint.should be_nil
402 op.pds_host.should be_nil
403 end
404 end
405
406 describe '#labeler_endpoint' do
407 it 'should return nil' do
408 op = subject.new(json)
409 op.labeler_endpoint.should be_nil
410 op.labeller_endpoint.should be_nil
411 op.labeler_host.should be_nil
412 op.labeller_host.should be_nil
413 end
414 end
415 end
416
417 context "when some services have endpoints that aren't valid URIs" do
418 let(:json) {
419 base_json.tap { |h|
420 h['operation']['services'] = {
421 "atproto_labeler" => {
422 "type" => "AtprotoLabeler",
423 "endpoint" => "bla bla bla"
424 },
425 "custom_service" => {
426 "type" => "OtherService",
427 "endpoint" => "https://custom.example.com"
428 }
429 }
430 }
431 }
432
433 it 'should only return valid services' do
434 op = subject.new(json)
435
436 op.services.length.should == 1
437 op.services[0].type == 'OtherService'
438 end
439 end
440 end
441end