forked from
lewis.moe/bspds-sandbox
I've been saying "PDSes seem easy enough, they're what, some CRUD to a db? I can do that in my sleep". well i'm sleeping rn so let's go
1use serde_json::json;
2use tranquil_pds::validation::{
3 RecordValidator, ValidationError, ValidationStatus, validate_collection_nsid,
4 validate_record_key,
5};
6
7fn now() -> String {
8 chrono::Utc::now().to_rfc3339()
9}
10
11#[test]
12fn test_post_record_validation() {
13 let validator = RecordValidator::new();
14
15 let valid_post = json!({
16 "$type": "app.bsky.feed.post",
17 "text": "Hello world!",
18 "createdAt": now()
19 });
20 assert_eq!(
21 validator
22 .validate(&valid_post, "app.bsky.feed.post")
23 .unwrap(),
24 ValidationStatus::Valid
25 );
26
27 let missing_text = json!({
28 "$type": "app.bsky.feed.post",
29 "createdAt": now()
30 });
31 assert!(
32 matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text")
33 );
34
35 let missing_created_at = json!({
36 "$type": "app.bsky.feed.post",
37 "text": "Hello"
38 });
39 assert!(
40 matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt")
41 );
42
43 let text_too_long = json!({
44 "$type": "app.bsky.feed.post",
45 "text": "a".repeat(3001),
46 "createdAt": now()
47 });
48 assert!(
49 matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text")
50 );
51
52 let text_at_limit = json!({
53 "$type": "app.bsky.feed.post",
54 "text": "a".repeat(3000),
55 "createdAt": now()
56 });
57 assert_eq!(
58 validator
59 .validate(&text_at_limit, "app.bsky.feed.post")
60 .unwrap(),
61 ValidationStatus::Valid
62 );
63
64 let too_many_langs = json!({
65 "$type": "app.bsky.feed.post",
66 "text": "Hello",
67 "createdAt": now(),
68 "langs": ["en", "fr", "de", "es"]
69 });
70 assert!(
71 matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs")
72 );
73
74 let three_langs_ok = json!({
75 "$type": "app.bsky.feed.post",
76 "text": "Hello",
77 "createdAt": now(),
78 "langs": ["en", "fr", "de"]
79 });
80 assert_eq!(
81 validator
82 .validate(&three_langs_ok, "app.bsky.feed.post")
83 .unwrap(),
84 ValidationStatus::Valid
85 );
86
87 let too_many_tags = json!({
88 "$type": "app.bsky.feed.post",
89 "text": "Hello",
90 "createdAt": now(),
91 "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"]
92 });
93 assert!(
94 matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags")
95 );
96
97 let eight_tags_ok = json!({
98 "$type": "app.bsky.feed.post",
99 "text": "Hello",
100 "createdAt": now(),
101 "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"]
102 });
103 assert_eq!(
104 validator
105 .validate(&eight_tags_ok, "app.bsky.feed.post")
106 .unwrap(),
107 ValidationStatus::Valid
108 );
109
110 let tag_too_long = json!({
111 "$type": "app.bsky.feed.post",
112 "text": "Hello",
113 "createdAt": now(),
114 "tags": ["t".repeat(641)]
115 });
116 assert!(
117 matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/"))
118 );
119}
120
121#[test]
122fn test_profile_record_validation() {
123 let validator = RecordValidator::new();
124
125 let valid = json!({
126 "$type": "app.bsky.actor.profile",
127 "displayName": "Test User",
128 "description": "A test user profile"
129 });
130 assert_eq!(
131 validator
132 .validate(&valid, "app.bsky.actor.profile")
133 .unwrap(),
134 ValidationStatus::Valid
135 );
136
137 let empty_ok = json!({
138 "$type": "app.bsky.actor.profile"
139 });
140 assert_eq!(
141 validator
142 .validate(&empty_ok, "app.bsky.actor.profile")
143 .unwrap(),
144 ValidationStatus::Valid
145 );
146
147 let displayname_too_long = json!({
148 "$type": "app.bsky.actor.profile",
149 "displayName": "n".repeat(641)
150 });
151 assert!(
152 matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
153 );
154
155 let description_too_long = json!({
156 "$type": "app.bsky.actor.profile",
157 "description": "d".repeat(2561)
158 });
159 assert!(
160 matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description")
161 );
162}
163
164#[test]
165fn test_like_and_repost_validation() {
166 let validator = RecordValidator::new();
167
168 let valid_like = json!({
169 "$type": "app.bsky.feed.like",
170 "subject": {
171 "uri": "at://did:plc:test/app.bsky.feed.post/123",
172 "cid": "bafyreig6xxxxxyyyyyzzzzzz"
173 },
174 "createdAt": now()
175 });
176 assert_eq!(
177 validator
178 .validate(&valid_like, "app.bsky.feed.like")
179 .unwrap(),
180 ValidationStatus::Valid
181 );
182
183 let missing_subject = json!({
184 "$type": "app.bsky.feed.like",
185 "createdAt": now()
186 });
187 assert!(
188 matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject")
189 );
190
191 let missing_subject_uri = json!({
192 "$type": "app.bsky.feed.like",
193 "subject": {
194 "cid": "bafyreig6xxxxxyyyyyzzzzzz"
195 },
196 "createdAt": now()
197 });
198 assert!(
199 matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri"))
200 );
201
202 let invalid_subject_uri = json!({
203 "$type": "app.bsky.feed.like",
204 "subject": {
205 "uri": "https://example.com/not-at-uri",
206 "cid": "bafyreig6xxxxxyyyyyzzzzzz"
207 },
208 "createdAt": now()
209 });
210 assert!(
211 matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri"))
212 );
213
214 let valid_repost = json!({
215 "$type": "app.bsky.feed.repost",
216 "subject": {
217 "uri": "at://did:plc:test/app.bsky.feed.post/123",
218 "cid": "bafyreig6xxxxxyyyyyzzzzzz"
219 },
220 "createdAt": now()
221 });
222 assert_eq!(
223 validator
224 .validate(&valid_repost, "app.bsky.feed.repost")
225 .unwrap(),
226 ValidationStatus::Valid
227 );
228
229 let repost_missing_subject = json!({
230 "$type": "app.bsky.feed.repost",
231 "createdAt": now()
232 });
233 assert!(
234 matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject")
235 );
236}
237
238#[test]
239fn test_follow_and_block_validation() {
240 let validator = RecordValidator::new();
241
242 let valid_follow = json!({
243 "$type": "app.bsky.graph.follow",
244 "subject": "did:plc:test12345",
245 "createdAt": now()
246 });
247 assert_eq!(
248 validator
249 .validate(&valid_follow, "app.bsky.graph.follow")
250 .unwrap(),
251 ValidationStatus::Valid
252 );
253
254 let missing_follow_subject = json!({
255 "$type": "app.bsky.graph.follow",
256 "createdAt": now()
257 });
258 assert!(
259 matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject")
260 );
261
262 let invalid_follow_subject = json!({
263 "$type": "app.bsky.graph.follow",
264 "subject": "not-a-did",
265 "createdAt": now()
266 });
267 assert!(
268 matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
269 );
270
271 let valid_block = json!({
272 "$type": "app.bsky.graph.block",
273 "subject": "did:plc:blocked123",
274 "createdAt": now()
275 });
276 assert_eq!(
277 validator
278 .validate(&valid_block, "app.bsky.graph.block")
279 .unwrap(),
280 ValidationStatus::Valid
281 );
282
283 let invalid_block_subject = json!({
284 "$type": "app.bsky.graph.block",
285 "subject": "not-a-did",
286 "createdAt": now()
287 });
288 assert!(
289 matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject")
290 );
291}
292
293#[test]
294fn test_list_and_graph_records_validation() {
295 let validator = RecordValidator::new();
296
297 let valid_list = json!({
298 "$type": "app.bsky.graph.list",
299 "name": "My List",
300 "purpose": "app.bsky.graph.defs#modlist",
301 "createdAt": now()
302 });
303 assert_eq!(
304 validator
305 .validate(&valid_list, "app.bsky.graph.list")
306 .unwrap(),
307 ValidationStatus::Valid
308 );
309
310 let list_name_too_long = json!({
311 "$type": "app.bsky.graph.list",
312 "name": "n".repeat(65),
313 "purpose": "app.bsky.graph.defs#modlist",
314 "createdAt": now()
315 });
316 assert!(
317 matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
318 );
319
320 let list_empty_name = json!({
321 "$type": "app.bsky.graph.list",
322 "name": "",
323 "purpose": "app.bsky.graph.defs#modlist",
324 "createdAt": now()
325 });
326 assert!(
327 matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name")
328 );
329
330 let valid_list_item = json!({
331 "$type": "app.bsky.graph.listitem",
332 "subject": "did:plc:test123",
333 "list": "at://did:plc:owner/app.bsky.graph.list/mylist",
334 "createdAt": now()
335 });
336 assert_eq!(
337 validator
338 .validate(&valid_list_item, "app.bsky.graph.listitem")
339 .unwrap(),
340 ValidationStatus::Valid
341 );
342}
343
344#[test]
345fn test_misc_record_types_validation() {
346 let validator = RecordValidator::new();
347
348 let valid_generator = json!({
349 "$type": "app.bsky.feed.generator",
350 "did": "did:web:example.com",
351 "displayName": "My Feed",
352 "createdAt": now()
353 });
354 assert_eq!(
355 validator
356 .validate(&valid_generator, "app.bsky.feed.generator")
357 .unwrap(),
358 ValidationStatus::Valid
359 );
360
361 let generator_displayname_too_long = json!({
362 "$type": "app.bsky.feed.generator",
363 "did": "did:web:example.com",
364 "displayName": "f".repeat(241),
365 "createdAt": now()
366 });
367 assert!(
368 matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName")
369 );
370
371 let valid_threadgate = json!({
372 "$type": "app.bsky.feed.threadgate",
373 "post": "at://did:plc:test/app.bsky.feed.post/123",
374 "createdAt": now()
375 });
376 assert_eq!(
377 validator
378 .validate(&valid_threadgate, "app.bsky.feed.threadgate")
379 .unwrap(),
380 ValidationStatus::Valid
381 );
382
383 let valid_labeler = json!({
384 "$type": "app.bsky.labeler.service",
385 "policies": {
386 "labelValues": ["spam", "nsfw"]
387 },
388 "createdAt": now()
389 });
390 assert_eq!(
391 validator
392 .validate(&valid_labeler, "app.bsky.labeler.service")
393 .unwrap(),
394 ValidationStatus::Valid
395 );
396}
397
398#[test]
399fn test_type_and_format_validation() {
400 let validator = RecordValidator::new();
401 let strict_validator = RecordValidator::new().require_lexicon(true);
402
403 let custom_record = json!({
404 "$type": "com.custom.record",
405 "data": "test"
406 });
407 assert_eq!(
408 validator
409 .validate(&custom_record, "com.custom.record")
410 .unwrap(),
411 ValidationStatus::Unknown
412 );
413 assert!(matches!(
414 strict_validator.validate(&custom_record, "com.custom.record"),
415 Err(ValidationError::UnknownType(_))
416 ));
417
418 let type_mismatch = json!({
419 "$type": "app.bsky.feed.like",
420 "subject": {"uri": "at://test", "cid": "bafytest"},
421 "createdAt": now()
422 });
423 assert!(matches!(
424 validator.validate(&type_mismatch, "app.bsky.feed.post"),
425 Err(ValidationError::TypeMismatch { expected, actual }) if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like"
426 ));
427
428 let missing_type = json!({
429 "text": "Hello"
430 });
431 assert!(matches!(
432 validator.validate(&missing_type, "app.bsky.feed.post"),
433 Err(ValidationError::MissingType)
434 ));
435
436 let not_object = json!("just a string");
437 assert!(matches!(
438 validator.validate(¬_object, "app.bsky.feed.post"),
439 Err(ValidationError::InvalidRecord(_))
440 ));
441
442 let valid_datetime = json!({
443 "$type": "app.bsky.feed.post",
444 "text": "Test",
445 "createdAt": "2024-01-15T10:30:00.000Z"
446 });
447 assert_eq!(
448 validator
449 .validate(&valid_datetime, "app.bsky.feed.post")
450 .unwrap(),
451 ValidationStatus::Valid
452 );
453
454 let datetime_with_offset = json!({
455 "$type": "app.bsky.feed.post",
456 "text": "Test",
457 "createdAt": "2024-01-15T10:30:00+05:30"
458 });
459 assert_eq!(
460 validator
461 .validate(&datetime_with_offset, "app.bsky.feed.post")
462 .unwrap(),
463 ValidationStatus::Valid
464 );
465
466 let invalid_datetime = json!({
467 "$type": "app.bsky.feed.post",
468 "text": "Test",
469 "createdAt": "2024/01/15"
470 });
471 assert!(matches!(
472 validator.validate(&invalid_datetime, "app.bsky.feed.post"),
473 Err(ValidationError::InvalidDatetime { .. })
474 ));
475}
476
477#[test]
478fn test_record_key_validation() {
479 assert!(validate_record_key("3k2n5j2").is_ok());
480 assert!(validate_record_key("valid-key").is_ok());
481 assert!(validate_record_key("valid_key").is_ok());
482 assert!(validate_record_key("valid.key").is_ok());
483 assert!(validate_record_key("valid~key").is_ok());
484 assert!(validate_record_key("self").is_ok());
485
486 assert!(matches!(
487 validate_record_key(""),
488 Err(ValidationError::InvalidRecord(_))
489 ));
490
491 assert!(validate_record_key(".").is_err());
492 assert!(validate_record_key("..").is_err());
493
494 assert!(validate_record_key("invalid/key").is_err());
495 assert!(validate_record_key("invalid key").is_err());
496 assert!(validate_record_key("invalid@key").is_err());
497 assert!(validate_record_key("invalid#key").is_err());
498
499 assert!(matches!(
500 validate_record_key(&"k".repeat(513)),
501 Err(ValidationError::InvalidRecord(_))
502 ));
503 assert!(validate_record_key(&"k".repeat(512)).is_ok());
504}
505
506#[test]
507fn test_collection_nsid_validation() {
508 assert!(validate_collection_nsid("app.bsky.feed.post").is_ok());
509 assert!(validate_collection_nsid("com.atproto.repo.record").is_ok());
510 assert!(validate_collection_nsid("a.b.c").is_ok());
511 assert!(validate_collection_nsid("my-app.domain.record-type").is_ok());
512
513 assert!(matches!(
514 validate_collection_nsid(""),
515 Err(ValidationError::InvalidRecord(_))
516 ));
517
518 assert!(validate_collection_nsid("a").is_err());
519 assert!(validate_collection_nsid("a.b").is_err());
520
521 assert!(validate_collection_nsid("a..b.c").is_err());
522 assert!(validate_collection_nsid(".a.b.c").is_err());
523 assert!(validate_collection_nsid("a.b.c.").is_err());
524
525 assert!(validate_collection_nsid("a.b.c/d").is_err());
526 assert!(validate_collection_nsid("a.b.c_d").is_err());
527 assert!(validate_collection_nsid("a.b.c@d").is_err());
528}