a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore(lexicons): add proper tests

Mary cc3a309a 7b590bd7

+1950 -96
+5 -1
packages/lexicons/lexicons/lib/interfaces/bytes.ts
··· 41 41 export const isBytes = (input: unknown): input is Bytes => { 42 42 const v = input as any; 43 43 44 - return typeof v === 'object' && v !== null && (BYTES_SYMBOL in v || isBase64(v.$bytes)); 44 + return ( 45 + typeof v === 'object' && 46 + v !== null && 47 + (BYTES_SYMBOL in v || (isBase64(v.$bytes) && Object.keys(v).length === 1)) 48 + ); 45 49 };
+62
packages/lexicons/lexicons/lib/syntax/at-identifier.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isActorIdentifier } from './at-identifier.js'; 4 + 5 + describe('at-identifier validation', () => { 6 + it('validates at-identifier', () => { 7 + const validCases = [ 8 + // allows valid handles 9 + 'XX.LCS.MIT.EDU', 10 + 'john.test', 11 + 'jan.test', 12 + 'a234567890123456789.test', 13 + 'john2.test', 14 + 'john-john.test', 15 + 16 + // allows valid DIDs 17 + 'did:method:val', 18 + 'did:method:VAL', 19 + 'did:method:val123', 20 + 'did:method:123', 21 + 'did:method:val-two', 22 + ]; 23 + for (const case_ of validCases) { 24 + expect(isActorIdentifier(case_), case_).toBe(true); 25 + } 26 + 27 + const invalidCases = [ 28 + // invalid handles 29 + 'did:thing.test', 30 + 'did:thing', 31 + 'john-.test', 32 + 'john.0', 33 + 'john.-', 34 + 'xn--bcher-.tld', 35 + 'john..test', 36 + 'jo_hn.test', 37 + 38 + // invalid DIDs 39 + 'did', 40 + 'didmethodval', 41 + 'method:did:val', 42 + 'did:method:', 43 + 'didmethod:val', 44 + 'did:methodval)', 45 + ':did:method:val', 46 + 'did:method:val:', 47 + 'did:method:val%', 48 + 'DID:method:val', 49 + 50 + // other invalid stuff 51 + 'email@example.com', 52 + '@handle@example.com', 53 + '@handle', 54 + 'blah', 55 + ]; 56 + for (const case_ of invalidCases) { 57 + expect(isActorIdentifier(case_), case_).toBe(false); 58 + } 59 + 60 + expect(isActorIdentifier(null)).toBe(false); 61 + }); 62 + });
+189
packages/lexicons/lexicons/lib/syntax/at-uri.test.ts
··· 1 + import { assert, describe, expect, it } from 'vitest'; 2 + 3 + import { 4 + isResourceUri, 5 + parseResourceUri, 6 + isCanonicalResourceUri, 7 + parseCanonicalResourceUri, 8 + } from './at-uri.js'; 9 + 10 + describe('resourceUri validation', () => { 11 + it('validates at-uri', () => { 12 + const validCases = [ 13 + // enforces spec basics 14 + 'at://did:plc:asdf123', 15 + 'at://user.bsky.social', 16 + 'at://did:plc:asdf123/com.atproto.feed.post', 17 + 'at://did:plc:asdf123/com.atproto.feed.post/record', 18 + 19 + // very long: 'at://did:plc:asdf123/com.atproto.feed.post/' + 'o'.repeat(512) 20 + 'at://did:plc:asdf123/com.atproto.feed.post/' + 'o'.repeat(512), 21 + 22 + // enforces no trailing slashes 23 + 'at://did:plc:asdf123', 24 + 'at://user.bsky.social', 25 + 'at://did:plc:asdf123/com.atproto.feed.post', 26 + 'at://did:plc:asdf123/com.atproto.feed.post/record', 27 + 28 + // enforces strict paths 29 + 'at://did:plc:asdf123/com.atproto.feed.post/asdf123', 30 + 31 + // is very permissive about record keys 32 + 'at://did:plc:asdf123/com.atproto.feed.post/asdf123', 33 + 'at://did:plc:asdf123/com.atproto.feed.post/a', 34 + 35 + 'at://did:plc:asdf123/com.atproto.feed.post/asdf-123', 36 + 'at://did:abc:123', 37 + 'at://did:abc:123/io.nsid.someFunc/record-key', 38 + 39 + 'at://did:abc:123/io.nsid.someFunc/self.', 40 + 'at://did:abc:123/io.nsid.someFunc/lang:', 41 + 'at://did:abc:123/io.nsid.someFunc/:', 42 + 'at://did:abc:123/io.nsid.someFunc/-', 43 + 'at://did:abc:123/io.nsid.someFunc/_', 44 + 'at://did:abc:123/io.nsid.someFunc/~', 45 + 'at://did:abc:123/io.nsid.someFunc/...', 46 + 'at://did:plc:asdf123/com.atproto.feed.postV2', 47 + ]; 48 + for (const str of validCases) { 49 + expect(isResourceUri(str), str).toBe(true); 50 + 51 + expect(parseResourceUri(str).ok, str).toBe(true); 52 + } 53 + 54 + const invalidCases = [ 55 + // enforces spec basics 56 + 'a://did:plc:asdf123', 57 + 'at//did:plc:asdf123', 58 + 'at:/a/did:plc:asdf123', 59 + 'at:/did:plc:asdf123', 60 + 'AT://did:plc:asdf123', 61 + 'http://did:plc:asdf123', 62 + '://did:plc:asdf123', 63 + 'at:did:plc:asdf123', 64 + 'at:/did:plc:asdf123', 65 + 'at:///did:plc:asdf123', 66 + 'at://:/did:plc:asdf123', 67 + 'at:/ /did:plc:asdf123', 68 + 'at://did:plc:asdf123 ', 69 + 'at://did:plc:asdf123/ ', 70 + ' at://did:plc:asdf123', 71 + 'at://did:plc:asdf123/com.atproto.feed.post ', 72 + 'at://did:plc:asdf123/com.atproto.feed.post# ', 73 + 'at://did:plc:asdf123/com.atproto.feed.post#/ ', 74 + 'at://did:plc:asdf123/com.atproto.feed.post#/frag ', 75 + 'at://did:plc:asdf123/com.atproto.feed.post#fr ag', 76 + '//did:plc:asdf123', 77 + 'at://name', 78 + 'at://name.0', 79 + 'at://diD:plc:asdf123', 80 + 'at://did:plc:asdf123/com.atproto.feed.p@st', 81 + 'at://did:plc:asdf123/com.atproto.feed.p$st', 82 + 'at://did:plc:asdf123/com.atproto.feed.p%st', 83 + 'at://did:plc:asdf123/com.atproto.feed.p&st', 84 + 'at://did:plc:asdf123/com.atproto.feed.p()t', 85 + 'at://did:plc:asdf123/com.atproto.feed_post', 86 + 'at://did:plc:asdf123/-com.atproto.feed.post', 87 + 'at://did:plc:asdf@123/com.atproto.feed.post', 88 + 'at://DID:plc:asdf123', 89 + 'at://user.bsky.123', 90 + 'at://bsky', 91 + 'at://did:plc:', 92 + 'at://frag', 93 + // too long: 'at://did:plc:asdf123/com.atproto.feed.post/' + 'o'.repeat(8200) 94 + 'at://did:plc:asdf123/com.atproto.feed.post/' + 'o'.repeat(8200), 95 + // enforces no trailing slashes 96 + 'at://did:plc:asdf123/', 97 + 'at://user.bsky.social/', 98 + 'at://did:plc:asdf123/com.atproto.feed.post/', 99 + 'at://did:plc:asdf123/com.atproto.feed.post/record/', 100 + 'at://did:plc:asdf123/com.atproto.feed.post/record/#/frag', 101 + // disallow dot / double-dot 102 + 'at://did:plc:asdf123/com.atproto.feed.post/.', 103 + 'at://did:plc:asdf123/com.atproto.feed.post/..', 104 + 105 + 'at://did::', 106 + ]; 107 + for (const str of invalidCases) { 108 + expect(isResourceUri(str), str).toBe(false); 109 + 110 + expect(parseResourceUri(str).ok, str).toBe(false); 111 + } 112 + 113 + expect(isResourceUri(null)).toBe(false); 114 + }); 115 + 116 + it('parses valid at-uris', () => { 117 + const result = parseResourceUri('at://did:plc:asdf123/com.atproto.feed.post/record'); 118 + 119 + assert(result.ok); 120 + expect(result.value).toEqual({ 121 + repo: 'did:plc:asdf123', 122 + collection: 'com.atproto.feed.post', 123 + rkey: 'record', 124 + fragment: undefined, 125 + }); 126 + }); 127 + 128 + it('parses at-uri with fragment', () => { 129 + const result = parseResourceUri('at://did:plc:asdf123/com.atproto.feed.post/record#/fragment'); 130 + assert(result.ok); 131 + expect(result.value).toEqual({ 132 + repo: 'did:plc:asdf123', 133 + collection: 'com.atproto.feed.post', 134 + rkey: 'record', 135 + fragment: '/fragment', 136 + }); 137 + }); 138 + 139 + it('returns error for invalid at-uri', () => { 140 + const result = parseResourceUri('invalid-uri'); 141 + assert(!result.ok); 142 + expect(result.error).toContain('invalid at-uri'); 143 + }); 144 + }); 145 + 146 + describe('canonicalResourceUri validation', () => { 147 + it('validates canonical at-uri', () => { 148 + const validCases = [ 149 + 'at://did:plc:asdf123/com.atproto.feed.post/record', 150 + 'at://did:web:example.com/com.example.test/key123', 151 + ]; 152 + for (const str of validCases) { 153 + expect(isCanonicalResourceUri(str), str).toBe(true); 154 + 155 + expect(parseCanonicalResourceUri(str).ok, str).toBe(true); 156 + } 157 + 158 + const invalidCases = [ 159 + 'invalid', 160 + 'at://user.bsky.social/com.atproto.feed.post/record', // handle instead of DID 161 + 'at://did:plc:asdf123/com.atproto.feed.post', // missing rkey 162 + 'at://did:plc:asdf123', // missing collection and rkey 163 + ]; 164 + for (const str of invalidCases) { 165 + expect(isCanonicalResourceUri(str), str).toBe(false); 166 + 167 + expect(parseCanonicalResourceUri(str).ok, str).toBe(false); 168 + } 169 + 170 + expect(isCanonicalResourceUri(null)).toBe(false); 171 + }); 172 + 173 + it('parses valid canonical at-uris', () => { 174 + const result = parseCanonicalResourceUri('at://did:plc:asdf123/com.atproto.feed.post/record'); 175 + assert(result.ok); 176 + expect(result.value).toEqual({ 177 + repo: 'did:plc:asdf123', 178 + collection: 'com.atproto.feed.post', 179 + rkey: 'record', 180 + fragment: undefined, 181 + }); 182 + }); 183 + 184 + it('returns error for invalid canonical at-uri', () => { 185 + const result = parseCanonicalResourceUri('at://user.bsky.social/com.atproto.feed.post/record'); 186 + assert(!result.ok); 187 + expect(result.error).toContain('invalid repo in canonical-at-uri'); 188 + }); 189 + });
+5 -1
packages/lexicons/lexicons/lib/syntax/at-uri.ts
··· 81 81 }; 82 82 83 83 // #__NO_SIDE_EFFECTS__ 84 - export const isCanonicalResourceUri = (input: string): input is CanonicalResourceUri => { 84 + export const isCanonicalResourceUri = (input: unknown): input is CanonicalResourceUri => { 85 + if (typeof input !== 'string') { 86 + return false; 87 + } 88 + 85 89 const match = ATURI_RE.exec(input); 86 90 if (match === null) { 87 91 return false;
+48
packages/lexicons/lexicons/lib/syntax/cid.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isCid } from './cid.js'; 4 + 5 + describe('cid validation', () => { 6 + it('validates cid', () => { 7 + const validCases = [ 8 + // examples from https://docs.ipfs.tech/concepts/content-addressing 9 + 'bafyreigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 10 + 11 + // https://github.com/ipfs-shipyard/is-ipfs/blob/master/test/test-cid.spec.ts 12 + // 'zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7', 13 + // 'bafybeie5gq4jxvzmsym6hjlwxej4rwdoxt7wadqvmmwbqi7r27fclha2va', 14 + 15 + // more contrived examples 16 + // 'mBcDxtdWx0aWhhc2g+', 17 + // 'z7x3CtScH765HvShXT', 18 + // 'zdj7WhuEjrB52m1BisYCtmjH1hSKa7yZ3jEZ9JcXaFRD51wVz', 19 + // '7134036155352661643226414134664076', 20 + // 'f017012202c5f688262e0ece8569aa6f94d60aad55ca8d9d83734e4a7430d0cff6588ec2b', 21 + ]; 22 + for (const case_ of validCases) { 23 + expect(isCid(case_), case_).toBe(true); 24 + } 25 + 26 + const invalidCases = [ 27 + 'example.com', 28 + 'https://example.com', 29 + 'cid:bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 30 + '.', 31 + '12345', 32 + 33 + // whitespace 34 + ' bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 35 + 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi ', 36 + 'bafybe igdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 37 + 38 + // old CIDv0 not supported 39 + 'QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR', 40 + 41 + // https://github.com/ipfs-shipyard/is-ipfs/blob/master/test/test-cid.spec.ts 42 + 'noop', 43 + ]; 44 + for (const case_ of invalidCases) { 45 + expect(isCid(case_), case_).toBe(false); 46 + } 47 + }); 48 + });
+120
packages/lexicons/lexicons/lib/syntax/datetime.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isDatetime } from './datetime.js'; 4 + 5 + describe('datetime validation', () => { 6 + it('validates datetime', () => { 7 + const validCases = [ 8 + // "preferred" 9 + '1985-04-12T23:20:50.123Z', 10 + '1985-04-12T23:20:50.000Z', 11 + '2000-01-01T00:00:00.000Z', 12 + '1985-04-12T23:20:50.123456Z', 13 + '1985-04-12T23:20:50.120Z', 14 + '1985-04-12T23:20:50.120000Z', 15 + 16 + // "supported" 17 + '1985-04-12T23:20:50.1235678912345Z', 18 + '1985-04-12T23:20:50.100Z', 19 + '1985-04-12T23:20:50Z', 20 + '1985-04-12T23:20:50.0Z', 21 + '1985-04-12T23:20:50.123+00:00', 22 + '1985-04-12T23:20:50.123-07:00', 23 + '1985-04-12T23:20:50.123+07:00', 24 + '1985-04-12T23:20:50.123+01:45', 25 + '0985-04-12T23:20:50.123-07:00', 26 + '1985-04-12T23:20:50.123-07:00', 27 + '0123-01-01T00:00:00.000Z', 28 + 29 + // various precisions, up through at least 12 digits 30 + '1985-04-12T23:20:50.1Z', 31 + '1985-04-12T23:20:50.12Z', 32 + '1985-04-12T23:20:50.123Z', 33 + '1985-04-12T23:20:50.1234Z', 34 + '1985-04-12T23:20:50.12345Z', 35 + '1985-04-12T23:20:50.123456Z', 36 + '1985-04-12T23:20:50.1234567Z', 37 + '1985-04-12T23:20:50.12345678Z', 38 + '1985-04-12T23:20:50.123456789Z', 39 + '1985-04-12T23:20:50.1234567890Z', 40 + '1985-04-12T23:20:50.12345678901Z', 41 + '1985-04-12T23:20:50.123456789012Z', 42 + 43 + // extreme but currently allowed 44 + '0010-12-31T23:00:00.000Z', 45 + '1000-12-31T23:00:00.000Z', 46 + '1900-12-31T23:00:00.000Z', 47 + '3001-12-31T23:00:00.000Z', 48 + ]; 49 + for (const case_ of validCases) { 50 + expect(isDatetime(case_), case_).toBe(true); 51 + } 52 + 53 + const invalidCases = [ 54 + // subtle changes to: 1985-04-12T23:20:50.123Z 55 + '1985-04-12T23:20:50.123z', 56 + '01985-04-12T23:20:50.123Z', 57 + '985-04-12T23:20:50.123Z', 58 + '1985-04-12T23:20:50.Z', 59 + '1985-04-32T23;20:50.123Z', 60 + 61 + // en-dash and em-dash 62 + '1985—04-32T23;20:50.123Z', 63 + '1985–04-32T23;20:50.123Z', 64 + 65 + // whitespace 66 + ' 1985-04-12T23:20:50.123Z', 67 + '1985-04-12T23:20:50.123Z ', 68 + '1985-04-12T 23:20:50.123Z', 69 + 70 + // not enough zero padding 71 + '1985-4-12T23:20:50.123Z', 72 + '1985-04-2T23:20:50.123Z', 73 + '1985-04-12T3:20:50.123Z', 74 + '1985-04-12T23:0:50.123Z', 75 + '1985-04-12T23:20:5.123Z', 76 + 77 + // too much zero padding 78 + '01985-04-12T23:20:50.123Z', 79 + '1985-004-12T23:20:50.123Z', 80 + '1985-04-012T23:20:50.123Z', 81 + '1985-04-12T023:20:50.123Z', 82 + '1985-04-12T23:020:50.123Z', 83 + '1985-04-12T23:20:050.123Z', 84 + 85 + // strict capitalization (ISO-8601) 86 + '1985-04-12t23:20:50.123Z', 87 + '1985-04-12T23:20:50.123z', 88 + 89 + // RFC-3339, but not ISO-8601 90 + '1985-04-12T23:20:50.123-00:00', 91 + '1985-04-12_23:20:50.123Z', 92 + '1985-04-12 23:20:50.123Z', 93 + 94 + // ISO-8601, but weird 95 + '1985-04-274T23:20:50.123Z', 96 + 97 + // timezone is required 98 + '1985-04-12T23:20:50.123', 99 + '1985-04-12T23:20:50', 100 + 101 + '1985-04-12', 102 + '1985-04-12T23:20Z', 103 + '1985-04-12T23:20:5Z', 104 + '1985-04-12T23:20:50.123', 105 + '+001985-04-12T23:20:50.123Z', 106 + '23:20:50.123Z', 107 + 108 + // superficial syntax parses ok, but are not valid datetimes for semantic reasons (eg, "month zero") 109 + '1985-00-12T23:20:50.123Z', 110 + '1985-04-00T23:20:50.123Z', 111 + '1985-13-12T23:20:50.123Z', 112 + '1985-04-12T25:20:50.123Z', 113 + '1985-04-12T23:99:50.123Z', 114 + '1985-04-12T23:20:61.123Z', 115 + ]; 116 + for (const case_ of invalidCases) { 117 + expect(isDatetime(case_), case_).toBe(false); 118 + } 119 + }); 120 + });
+61
packages/lexicons/lexicons/lib/syntax/did.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isDid } from './did.js'; 4 + 5 + describe('did validation', () => { 6 + it('validates did', () => { 7 + const validCases = [ 8 + 'did:method:val', 9 + 'did:method:VAL', 10 + 'did:method:val123', 11 + 'did:method:123', 12 + 'did:method:val-two', 13 + 'did:method:val_two', 14 + 'did:method:val.two', 15 + 'did:method:val:two', 16 + 'did:method:val%BB', 17 + 'did:method:' + 'v'.repeat(200), 18 + 'did:m:v', 19 + 'did:method::::val', 20 + 'did:method:-', 21 + 'did:method:-:_:.:%ab', 22 + 'did:method:.', 23 + 'did:method:_', 24 + 'did:method::.', 25 + 26 + // allows some real DID values 27 + 'did:onion:2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid', 28 + 'did:example:123456789abcdefghi', 29 + 'did:plc:7iza6de2dwap2sbkpav7c6c6', 30 + 'did:web:example.com', 31 + 'did:web:localhost%3A1234', 32 + 'did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N', 33 + 'did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a', 34 + ]; 35 + for (const case_ of validCases) { 36 + expect(isDid(case_), case_).toBe(true); 37 + } 38 + 39 + const invalidCases = [ 40 + 'did', 41 + 'didmethodval', 42 + 'method:did:val', 43 + 'did:method:', 44 + 'didmethod:val', 45 + 'did:methodval)', 46 + ':did:method:val', 47 + 'did.method.val', 48 + 'did:method:val:', 49 + 'did:method:val%', 50 + 'DID:method:val', 51 + 'did:METHOD:val', 52 + 'did:m123:val', 53 + 'did:method:val/two', 54 + 'did:method:val?two', 55 + 'did:method:val#two', 56 + ]; 57 + for (const case_ of invalidCases) { 58 + expect(isDid(case_), case_).toBe(false); 59 + } 60 + }); 61 + });
+165
packages/lexicons/lexicons/lib/syntax/handle.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isHandle } from './handle.js'; 4 + 5 + describe('handle validation', () => { 6 + it('validates handle', () => { 7 + const validCases = [ 8 + // allows valid handles 9 + 'A.ISI.EDU', 10 + 'XX.LCS.MIT.EDU', 11 + 'SRI-NIC.ARPA', 12 + 'john.test', 13 + 'jan.test', 14 + 'a234567890123456789.test', 15 + 'john2.test', 16 + 'john-john.test', 17 + 'john.bsky.app', 18 + 'jo.hn', 19 + 'a.co', 20 + 'a.org', 21 + 'joh.n', 22 + 'j0.h0', 23 + 'jaymome-johnber123456.test', 24 + 'jay.mome-johnber123456.test', 25 + 'john.test.bsky.app', 26 + 27 + // max over all handle: 'shoooort' + '.loooooooooooooooooooooooooong'.repeat(8) + '.test' 28 + 'shoooort' + '.loooooooooooooooooooooooooong'.repeat(8) + '.test', 29 + 30 + // max segment: 'short.' + 'o'.repeat(63) + '.test' 31 + 'short.' + 'o'.repeat(63) + '.test', 32 + 33 + // NOTE: this probably isn't ever going to be a real domain, but my read of the RFC is that it would be possible 34 + 'john.t', 35 + 36 + // allows .local and .arpa handles (proto-level) 37 + 'laptop.local', 38 + 'laptop.arpa', 39 + 40 + // allows punycode handles 41 + // 💩.test 42 + 'xn--ls8h.test', 43 + // bücher.tld 44 + 'xn--bcher-kva.tld', 45 + 'xn--3jk.com', 46 + 'xn--w3d.com', 47 + 'xn--vqb.com', 48 + 'xn--ppd.com', 49 + 'xn--cs9a.com', 50 + 'xn--8r9a.com', 51 + 'xn--cfd.com', 52 + 'xn--5jk.com', 53 + 'xn--2lb.com', 54 + 55 + // allows onion (Tor) handles 56 + 'expyuzz4wqqyqhjn.onion', 57 + 'friend.expyuzz4wqqyqhjn.onion', 58 + 'g2zyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion', 59 + 'friend.g2zyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion', 60 + '2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion', 61 + 'friend.2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion', 62 + 63 + // correctly validates corner cases (modern vs. old RFCs) 64 + '12345.test', 65 + '8.cn', 66 + '4chan.org', 67 + '4chan.o-g', 68 + 'blah.4chan.org', 69 + 'thing.a01', 70 + '120.0.0.1.com', 71 + '0john.test', 72 + '9sta--ck.com', 73 + '99stack.com', 74 + '0ohn.test', 75 + 'john.t--t', 76 + 'thing.0aa.thing', 77 + 78 + // examples from stackoverflow 79 + 'stack.com', 80 + 'sta-ck.com', 81 + 'sta---ck.com', 82 + 'sta--ck9.com', 83 + 'stack99.com', 84 + 'sta99ck.com', 85 + 'google.com.uk', 86 + 'google.co.in', 87 + 'google.com', 88 + 'maselkowski.pl', 89 + 'm.maselkowski.pl', 90 + 'xn--masekowski-d0b.pl', 91 + 'xn--fiqa61au8b7zsevnm8ak20mc4a87e.xn--fiqs8s', 92 + 'xn--stackoverflow.com', 93 + 'stackoverflow.xn--com', 94 + 'stackoverflow.co.uk', 95 + ]; 96 + for (const case_ of validCases) { 97 + expect(isHandle(case_), case_).toBe(true); 98 + } 99 + 100 + const invalidCases = [ 101 + // throws on invalid handles 102 + 'did:thing.test', 103 + 'did:thing', 104 + 'john-.test', 105 + 'john.0', 106 + 'john.-', 107 + 'xn--bcher-.tld', 108 + 'john..test', 109 + 'jo_hn.test', 110 + '-john.test', 111 + '.john.test', 112 + 'jo!hn.test', 113 + 'jo%hn.test', 114 + 'jo&hn.test', 115 + 'jo@hn.test', 116 + 'jo*hn.test', 117 + 'jo|hn.test', 118 + 'jo:hn.test', 119 + 'jo/hn.test', 120 + 'john💩.test', 121 + 'bücher.test', 122 + 'john .test', 123 + 'john.test.', 124 + 'john', 125 + 'john.', 126 + '.john', 127 + '.john.test', 128 + ' john.test', 129 + 'john.test ', 130 + 'joh-.test', 131 + 'john.-est', 132 + 'john.tes-', 133 + 134 + // max over all handle: 'shoooort' + '.loooooooooooooooooooooooooong'.repeat(9) + '.test' 135 + 'shoooort' + '.loooooooooooooooooooooooooong'.repeat(9) + '.test', 136 + 137 + // max segment: 'short.' + 'o'.repeat(64) + '.test' 138 + 'short.' + 'o'.repeat(64) + '.test', 139 + 140 + // throws on "dotless" TLD handles 141 + 'org', 142 + 'ai', 143 + 'gg', 144 + 'io', 145 + 146 + // correctly validates corner cases (modern vs. old RFCs) 147 + 'cn.8', 148 + 'thing.0aa', 149 + 150 + // does not allow IP addresses as handles 151 + '127.0.0.1', 152 + '192.168.0.142', 153 + 'fe80::7325:8a97:c100:94b', 154 + '2600:3c03::f03c:9100:feb0:af1f', 155 + 156 + // examples from stackoverflow 157 + '-notvalid.at-all', 158 + '-thing.com', 159 + 'www.masełkowski.pl.com', 160 + ]; 161 + for (const case_ of invalidCases) { 162 + expect(isHandle(case_), case_).toBe(false); 163 + } 164 + }); 165 + });
+44
packages/lexicons/lexicons/lib/syntax/language.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isLanguageCode } from './language.js'; 4 + 5 + describe('language code validation', () => { 6 + it('validates language code', () => { 7 + const validCases = [ 8 + 'ja', 9 + 'ban', 10 + 'pt-BR', 11 + 'hy-Latn-IT-arevela', 12 + 'en-GB', 13 + 'zh-Hant', 14 + 'sgn-BE-NL', 15 + 'es-419', 16 + 'en-GB-boont-r-extended-sequence-x-private', 17 + 18 + // grandfathered 19 + 'zh-hakka', 20 + 'i-default', 21 + 'i-navajo', 22 + 23 + // https://github.com/sebinsua/ietf-language-tag-regex/blob/master/test.js 24 + 'de-CH-1901', 25 + 'qaa-Qaaa-QM-x-southern', 26 + ]; 27 + for (const case_ of validCases) { 28 + expect(isLanguageCode(case_), case_).toBe(true); 29 + } 30 + 31 + const invalidCases = [ 32 + // 'jaja', 33 + '.', 34 + '123', 35 + // 'JA', 36 + 'j', 37 + 'ja-', 38 + 'a-DE', 39 + ]; 40 + for (const case_ of invalidCases) { 41 + expect(isLanguageCode(case_), case_).toBe(false); 42 + } 43 + }); 44 + });
+79
packages/lexicons/lexicons/lib/syntax/nsid.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isNsid } from './nsid.js'; 4 + 5 + describe('nsid validation', () => { 6 + it('validates nsid', () => { 7 + const validCases = [ 8 + // length checks 9 + 'com.' + 'o'.repeat(63) + '.foo', 10 + 'com.example.' + 'o'.repeat(63), 11 + 'com.' + 'middle.'.repeat(40) + 'foo', 12 + 13 + // valid examples 14 + 'com.example.fooBar', 15 + 'com.example.fooBarV2', 16 + 'net.users.bob.ping', 17 + 'a.b.c', 18 + 'm.xn--masekowski-d0b.pl', 19 + 'one.two.three', 20 + 'one.two.three.four-and.FiVe', 21 + 'one.2.three', 22 + 'a-0.b-1.c', 23 + 'a0.b1.cc', 24 + 'cn.8.lex.stuff', 25 + 'test.12345.record', 26 + 'a01.thing.record', 27 + 'a.0.c', 28 + 'xn--fiqs8s.xn--fiqa61au8b7zsevnm8ak20mc4a87e.record.two', 29 + 'a0.b1.c3', 30 + 'com.example.f00', 31 + 32 + // allows onion (Tor) NSIDs 33 + 'onion.expyuzz4wqqyqhjn.spec.getThing', 34 + 'onion.g2zyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.lex.deleteThing', 35 + 36 + // allows starting-with-numeric segments (same as domains) 37 + 'org.4chan.lex.getThing', 38 + 'cn.8.lex.stuff', 39 + 'onion.2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.lex.deleteThing', 40 + ]; 41 + for (const case_ of validCases) { 42 + expect(isNsid(case_), case_).toBe(true); 43 + } 44 + 45 + const invalidCases = [ 46 + // length checks 47 + 'com.' + 'o'.repeat(64) + '.foo', 48 + 'com.example.' + 'o'.repeat(64), 49 + 'com.' + 'middle.'.repeat(50) + 'foo', 50 + // invalid examples 51 + 'com.example.foo.*', 52 + 'com.example.foo.blah*', 53 + 'com.example.foo.*blah', 54 + 'com.exa💩ple.thing', 55 + 'a-0.b-1.c-3', 56 + 'a-0.b-1.c-o', 57 + '1.0.0.127.record', 58 + '0two.example.foo', 59 + 'example.com', 60 + 'com.example', 61 + 'a.', 62 + '.one.two.three', 63 + 'one.two.three ', 64 + 'one.two..three', 65 + 'one .two.three', 66 + ' one.two.three', 67 + 'com.atproto.feed.p@st', 68 + 'com.atproto.feed.p_st', 69 + 'com.atproto.feed.p*st', 70 + 'com.atproto.feed.po#t', 71 + 'com.atproto.feed.p!ot', 72 + 'com.example-.foo', 73 + 'com.example.fooBar.2', 74 + ]; 75 + for (const case_ of invalidCases) { 76 + expect(isNsid(case_), case_).toBe(false); 77 + } 78 + }); 79 + });
+51
packages/lexicons/lexicons/lib/syntax/record-key.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isRecordKey } from './record-key.js'; 4 + 5 + describe('record key validation', () => { 6 + it('validates record key', () => { 7 + const validCases = [ 8 + // specs 9 + 'self', 10 + 'example.com', 11 + '~1.2-3_', 12 + 'dHJ1ZQ', 13 + '_', 14 + 'literal:self', 15 + 'pre:fix', 16 + // more corner-cases 17 + ':', 18 + '-', 19 + '~', 20 + '...', 21 + 'self.', 22 + 'lang:', 23 + ':lang', 24 + // very long: 'o'.repeat(512) 25 + 'o'.repeat(512), 26 + ]; 27 + for (const case_ of validCases) { 28 + expect(isRecordKey(case_), case_).toBe(true); 29 + } 30 + 31 + const invalidCases = [ 32 + // specs 33 + 'alpha/beta', 34 + '.', 35 + '..', 36 + '#extra', 37 + '@handle', 38 + 'any space', 39 + 'any+space', 40 + 'number[3]', 41 + 'number(3)', 42 + '"quote"', 43 + 'dHJ1ZQ==', 44 + // too long: 'o'.repeat(513) 45 + 'o'.repeat(513), 46 + ]; 47 + for (const case_ of invalidCases) { 48 + expect(isRecordKey(case_), case_).toBe(false); 49 + } 50 + }); 51 + });
+43
packages/lexicons/lexicons/lib/syntax/tid.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isTid } from './tid.js'; 4 + 5 + describe('tid validation', () => { 6 + it('validates tid', () => { 7 + const validCases = [ 8 + // 13 digits 9 + // 234567abcdefghijklmnopqrstuvwxyz 10 + '3jzfcijpj2z2a', 11 + '7777777777777', 12 + '3zzzzzzzzzzzz', 13 + '2222222222222', 14 + ]; 15 + for (const case_ of validCases) { 16 + expect(isTid(case_), case_).toBe(true); 17 + } 18 + 19 + const invalidCases = [ 20 + // not base32 21 + '3jzfcijpj2z21', 22 + '0000000000000', 23 + 24 + // case-sensitive 25 + '3JZFCIJPJ2Z2A', 26 + 27 + // too long/short 28 + '3jzfcijpj2z2aa', 29 + '3jzfcijpj2z2', 30 + '222', 31 + 32 + // old dashes syntax not actually supported (TTTT-TTT-TTTT-CC) 33 + '3jzf-cij-pj2z-2a', 34 + 35 + // high bit can't be high 36 + 'zzzzzzzzzzzzz', 37 + 'kjzfcijpj2z2a', 38 + ]; 39 + for (const case_ of invalidCases) { 40 + expect(isTid(case_), case_).toBe(false); 41 + } 42 + }); 43 + });
+44
packages/lexicons/lexicons/lib/syntax/uri.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import { isGenericUri } from './uri.js'; 4 + 5 + describe('uri validation', () => { 6 + it('validates uri', () => { 7 + const validCases = [ 8 + 'https://example.com', 9 + 'https://example.com/path?q=blah&yes=true#frag.123', 10 + 'dns:example.com', 11 + 'at://handle.example.com/nsid/rkey', 12 + 'did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N', 13 + // 'content-type:text/plan', 14 + // 'microsoft.windows.camera:thing', 15 + 'go://?Mercedes%20Benz', 16 + 17 + // long (but not too long) 18 + // python: "https://example.com/" + 5000*"x" 19 + 'https://example.com/' + 'x'.repeat(5000), 20 + ]; 21 + for (const case_ of validCases) { 22 + expect(isGenericUri(case_), case_).toBe(true); 23 + } 24 + 25 + const invalidCases = [ 26 + 'example.com', 27 + '://example.com', 28 + '//example.com', 29 + 'http:', 30 + '.http://example.com', 31 + '-http://example.com', 32 + '12345', 33 + '127.0.0.1', 34 + 'https://example.com/path gap', 35 + ' https://example.com/path', 36 + 'https://example.com/trailing-whitespace ', 37 + // too long (max 8 kbytes): python: "https://example.com/" + 8200 *"x" 38 + 'https://example.com/' + 'x'.repeat(8200), 39 + ]; 40 + for (const case_ of invalidCases) { 41 + expect(isGenericUri(case_), case_).toBe(false); 42 + } 43 + }); 44 + });
+1026 -94
packages/lexicons/lexicons/lib/validations/index.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 1 + import { assert, describe, expect, it, vi } from 'vitest'; 2 + 3 + import { toBytes } from '@atcute/cbor'; 4 + import { fromBase64 } from '@atcute/multibase'; 2 5 3 6 import * as v from './index.js'; 4 7 import { allowsEval } from './utils.js'; 5 8 6 - describe.each([[false], [true]])(`with eval: %p`, (withEval) => { 7 - it('performs validation', () => { 8 - using _mock = vi.spyOn(allowsEval, 'value', 'get').mockReturnValue(withEval); 9 + describe(`validation errors`, () => { 10 + it(`throws ValidationError`, () => { 11 + const schema = v.literal('alice'); 12 + 13 + { 14 + const result = v.safeParse(schema, 'bob'); 15 + 16 + assert(!result.ok); 17 + expect(() => result.throw()).toThrow(v.ValidationError); 18 + } 19 + 20 + { 21 + let caught: any; 22 + 23 + try { 24 + v.parse(schema, 'mallory'); 25 + } catch (err) { 26 + caught = err; 27 + } 28 + 29 + if (caught === undefined) { 30 + expect.fail(`expected validation to throw`); 31 + } 9 32 10 - const subobjectSchema = v.object({ 11 - $type: v.optional(v.literal('com.example.kitchenSink#subobject')), 12 - boolean: v.boolean(), 33 + expect(caught).toBeInstanceOf(v.ValidationError); 34 + expect(caught).toEqual( 35 + expect.objectContaining({ 36 + name: 'ValidationError', 37 + message: 'invalid_literal at . (expected "alice")', 38 + }), 39 + ); 40 + } 41 + }); 42 + }); 43 + 44 + describe(`literal types`, () => { 45 + it(`validates string literal values`, () => { 46 + const schema = v.literal('bob'); 47 + 48 + expect(v.is(schema, 'bob')).toBe(true); 49 + expect(v.is(schema, 'alice')).toBe(false); 50 + 51 + expect(v.is(schema, false)).toBe(false); 52 + expect(v.is(schema, true)).toBe(false); 53 + expect(v.is(schema, 0)).toBe(false); 54 + expect(v.is(schema, 1)).toBe(false); 55 + 56 + { 57 + const result = v.safeParse(schema, 'mallory'); 58 + 59 + assert(!result.ok, `expected validation issue`); 60 + expect(result.message).toBe('invalid_literal at . (expected "bob")'); 61 + expect(result.issues).toEqual([{ code: 'invalid_literal', expected: ['bob'], path: [] }]); 62 + } 63 + 64 + { 65 + const result = v.safeParse(schema, 123); 66 + 67 + assert(!result.ok, `expected validation issue`); 68 + expect(result.message).toBe('invalid_literal at . (expected "bob")'); 69 + expect(result.issues).toEqual([{ code: 'invalid_literal', expected: ['bob'], path: [] }]); 70 + } 71 + }); 72 + 73 + it(`validates string enum values`, () => { 74 + const schema = v.literalEnum(['alice', 'bob', 'eris']); 75 + 76 + expect(v.is(schema, 'alice')).toBe(true); 77 + expect(v.is(schema, 'bob')).toBe(true); 78 + expect(v.is(schema, 'mallory')).toBe(false); 79 + 80 + expect(v.is(schema, false)).toBe(false); 81 + expect(v.is(schema, true)).toBe(false); 82 + expect(v.is(schema, 0)).toBe(false); 83 + expect(v.is(schema, 1)).toBe(false); 84 + 85 + { 86 + const result = v.safeParse(schema, 'mallory'); 87 + 88 + assert(!result.ok, `expected validation issue`); 89 + expect(result.message).toBe('invalid_literal at . (expected "alice", "bob" or "eris")'); 90 + expect(result.issues).toEqual([ 91 + { code: 'invalid_literal', expected: ['alice', 'bob', 'eris'], path: [] }, 92 + ]); 93 + } 94 + 95 + { 96 + const result = v.safeParse(schema, 123); 97 + 98 + assert(!result.ok, `expected validation issue`); 99 + expect(result.message).toBe('invalid_literal at . (expected "alice", "bob" or "eris")'); 100 + expect(result.issues).toEqual([ 101 + { code: 'invalid_literal', expected: ['alice', 'bob', 'eris'], path: [] }, 102 + ]); 103 + } 104 + }); 105 + 106 + it.todo(`validates integer literal values`, () => {}); 107 + 108 + it.todo(`validates integer enum values`, () => {}); 109 + }); 110 + 111 + describe(`primitive types`, () => { 112 + it(`validates boolean type`, () => { 113 + const schema = v.boolean(); 114 + 115 + expect(v.is(schema, false)).toBe(true); 116 + expect(v.is(schema, true)).toBe(true); 117 + 118 + expect(v.is(schema, 0)).toBe(false); 119 + expect(v.is(schema, 1)).toBe(false); 120 + expect(v.is(schema, '')).toBe(false); 121 + expect(v.is(schema, 'hello')).toBe(false); 122 + 123 + { 124 + const result = v.safeParse(schema, 'world'); 125 + 126 + assert(!result.ok, `expected validation issue`); 127 + expect(result.message).toBe('invalid_type at . (expected boolean)'); 128 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'boolean', path: [] }]); 129 + } 130 + }); 131 + 132 + it(`validates integer type`, () => { 133 + const schema = v.integer(); 134 + 135 + expect(v.is(schema, 0)).toBe(true); 136 + expect(v.is(schema, 1)).toBe(true); 137 + expect(v.is(schema, Number.MAX_SAFE_INTEGER)).toBe(true); 138 + 139 + expect(v.is(schema, -2)).toBe(false); 140 + expect(v.is(schema, 1.23)).toBe(false); 141 + expect(v.is(schema, Number.MAX_SAFE_INTEGER + 1)).toBe(false); 142 + 143 + expect(v.is(schema, false)).toBe(false); 144 + expect(v.is(schema, true)).toBe(false); 145 + expect(v.is(schema, '')).toBe(false); 146 + expect(v.is(schema, 'hello')).toBe(false); 147 + 148 + { 149 + const result = v.safeParse(schema, -2); 150 + 151 + assert(!result.ok, `expected validation issue`); 152 + expect(result.message).toBe('invalid_type at . (expected integer)'); 153 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'integer', path: [] }]); 154 + } 155 + 156 + { 157 + const result = v.safeParse(schema, 'world'); 158 + 159 + assert(!result.ok, `expected validation issue`); 160 + expect(result.message).toBe('invalid_type at . (expected integer)'); 161 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'integer', path: [] }]); 162 + } 163 + }); 164 + 165 + it(`validates string type`, () => { 166 + const schema = v.string(); 167 + 168 + expect(v.is(schema, '')).toBe(true); 169 + expect(v.is(schema, 'hello')).toBe(true); 170 + 171 + expect(v.is(schema, false)).toBe(false); 172 + expect(v.is(schema, true)).toBe(false); 173 + expect(v.is(schema, 0)).toBe(false); 174 + expect(v.is(schema, 1)).toBe(false); 175 + 176 + { 177 + const result = v.safeParse(schema, 123); 178 + 179 + assert(!result.ok, `expected validation issue`); 180 + expect(result.message).toBe('invalid_type at . (expected string)'); 181 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'string', path: [] }]); 182 + } 183 + }); 184 + 185 + it(`validates unknown type`, () => { 186 + const schema = v.unknown(); 187 + 188 + expect(v.is(schema, { hello: 'world' })).toBe(true); 189 + expect(v.is(schema, {})).toBe(true); 190 + 191 + expect(v.is(schema, 'hello')).toBe(false); 192 + expect(v.is(schema, 123)).toBe(false); 193 + 194 + { 195 + const result = v.safeParse(schema, 123); 196 + 197 + assert(!result.ok, `expected validation issue`); 198 + expect(result.message).toBe('invalid_type at . (expected unknown)'); 199 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'unknown', path: [] }]); 200 + } 201 + }); 202 + }); 203 + 204 + it(`validates blob type`, () => { 205 + const schema = v.blob(); 206 + 207 + expect( 208 + v.is(schema, { 209 + $type: 'blob', 210 + ref: { $link: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a' }, 211 + mimeType: 'image/png', 212 + size: 1024, 213 + }), 214 + ).toBe(true); 215 + 216 + expect( 217 + v.is(schema, { 218 + cid: 'bafkreidjmlrsggn2shrihfyp4iwlmxdp4dso7iqbkhfrpq6ahm22obop34', 219 + mimeType: 'image/jpeg', 220 + }), 221 + ).toBe(true); 222 + 223 + expect( 224 + v.is(schema, { 225 + $type: 'blob', 226 + ref: { $link: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a' }, 227 + mimeType: 'image/png', 228 + size: 1024, 229 + extra: 'hello', 230 + }), 231 + ).toBe(false); 232 + 233 + expect( 234 + v.is(schema, { 235 + $type: 'blob', 236 + ref: { $link: '' }, 237 + mimeType: 'image/png', 238 + size: 1024, 239 + }), 240 + ).toBe(false); 241 + 242 + expect( 243 + v.is(schema, { 244 + cid: 'bafkreidjmlrsggn2shrihfyp4iwlmxdp4dso7iqbkhfrpq6ahm22obop34', 245 + mimeType: 'image/jpeg', 246 + extra: 'hello', 247 + }), 248 + ).toBe(false); 249 + 250 + { 251 + const converted = v.parse(schema, { 252 + cid: 'bafkreidjmlrsggn2shrihfyp4iwlmxdp4dso7iqbkhfrpq6ahm22obop34', 253 + mimeType: 'image/jpeg', 13 254 }); 14 255 15 - const objectSchema = v.object({ 16 - $type: v.optional(v.literal('com.example.kitchenSink#object')), 17 - get object() { 18 - return subobjectSchema; 19 - }, 20 - array: v.array(v.string()), 21 - boolean: v.boolean(), 22 - integer: v.integer(), 23 - integerFilled: v.optional(v.integer()), 24 - integerOptional: v.optional(v.integer()), 25 - integerWithDefault: v.optional(v.integer(), 42), 26 - integerWithDefaultFn: v.optional(v.integer(), () => 421), 27 - string: v.string(), 256 + expect(converted).toEqual({ 257 + $type: 'blob', 258 + mimeType: 'image/jpeg', 259 + ref: { $link: 'bafkreidjmlrsggn2shrihfyp4iwlmxdp4dso7iqbkhfrpq6ahm22obop34' }, 260 + size: -1, 28 261 }); 262 + } 29 263 30 - const recordSchema = v.record( 31 - v.tidString(), 32 - v.object({ 33 - $type: v.literal('com.example.kitchenSink'), 34 - get object() { 35 - return objectSchema; 36 - }, 37 - array: v.array(v.string()), 38 - boolean: v.boolean(), 39 - integer: v.integer(), 40 - string: v.string(), 264 + { 265 + const result = v.safeParse(schema, 123); 41 266 42 - atUri: v.resourceUriString(), 43 - datetime: v.datetimeString(), 44 - did: v.didString(), 45 - cid: v.cidString(), 267 + assert(!result.ok, `expected validation issue`); 268 + expect(result.message).toBe('invalid_type at . (expected blob)'); 269 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'blob', path: [] }]); 270 + } 271 + }); 46 272 47 - bytes: v.bytes(), 48 - cidLink: v.cidLink(), 49 - }), 50 - ); 273 + describe(`IPLD types`, () => { 274 + it(`validates bytes type`, () => { 275 + const schema = v.bytes(); 51 276 52 - const datetime = new Date().toISOString(); 277 + expect(v.is(schema, { $bytes: 'a2VsaW5jaQ==' })).toBe(true); 278 + expect(v.is(schema, { $bytes: 'YnVubnk=' })).toBe(true); 279 + expect(v.is(schema, { $bytes: '' })).toBe(true); 53 280 54 - const res: v.InferInput<typeof recordSchema> = { 55 - $type: 'com.example.kitchenSink', 56 - object: { 57 - object: { boolean: true }, 58 - array: ['one', 'two'], 59 - boolean: true, 60 - integer: 123, 61 - integerFilled: 234, 62 - string: 'string', 63 - }, 64 - array: ['one', 'two'], 65 - boolean: true, 66 - integer: 123, 67 - string: 'string', 68 - datetime: datetime, 69 - atUri: 'at://did:web:example.com/com.example.test/self', 70 - did: 'did:web:example.com', 71 - cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', 72 - bytes: { 73 - $bytes: 'AAECAw', 74 - }, 75 - cidLink: { 76 - $link: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', 77 - }, 78 - }; 281 + expect(v.is(schema, toBytes(new Uint8Array([1, 2, 3])))).toBe(true); 79 282 80 - const result = v.parse(recordSchema, res); 81 - expect(result).toEqual({ 82 - $type: 'com.example.kitchenSink', 83 - array: ['one', 'two'], 84 - atUri: 'at://did:web:example.com/com.example.test/self', 85 - boolean: true, 86 - bytes: { 87 - $bytes: 'AAECAw', 88 - }, 89 - cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', 90 - cidLink: { 91 - $link: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', 92 - }, 93 - datetime: datetime, 94 - did: 'did:web:example.com', 95 - integer: 123, 96 - object: { 97 - array: ['one', 'two'], 98 - boolean: true, 99 - integer: 123, 100 - integerFilled: 234, 101 - integerWithDefault: 42, 102 - integerWithDefaultFn: 421, 103 - object: { 104 - boolean: true, 105 - }, 106 - string: 'string', 107 - }, 108 - string: 'string', 283 + expect(v.is(schema, { $bytes: 'a2VsaW5jaQ===' })).toBe(false); 284 + expect(v.is(schema, { $bytes: 'a2VsaW5jaQ=' })).toBe(false); 285 + expect(v.is(schema, { $bytes: 'a2VsaW5j@Q==' })).toBe(false); 286 + expect(v.is(schema, { $bytes: 'a2Vs aW5jaQ==' })).toBe(false); 287 + expect(v.is(schema, { $bytes: '=' })).toBe(false); 288 + expect(v.is(schema, { $bytes: '!' })).toBe(false); 289 + 290 + { 291 + const result = v.safeParse(schema, 123); 292 + 293 + assert(!result.ok, `expected validation issue`); 294 + expect(result.message).toBe('invalid_type at . (expected bytes)'); 295 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'bytes', path: [] }]); 296 + } 297 + 298 + { 299 + const result = v.safeParse(schema, { $bytes: '=' }); 300 + 301 + assert(!result.ok, `expected validation issue`); 302 + expect(result.message).toBe('invalid_type at . (expected bytes)'); 303 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'bytes', path: [] }]); 304 + } 305 + }); 306 + 307 + it(`validates cid-link type`, () => { 308 + const schema = v.cidLink(); 309 + 310 + expect(v.is(schema, { $link: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a' })).toBe(true); 311 + 312 + expect(v.is(schema, { $link: '' })).toBe(false); 313 + 314 + { 315 + const result = v.safeParse(schema, 123); 316 + 317 + assert(!result.ok, `expected validation issue`); 318 + expect(result.message).toBe('invalid_type at . (expected cid-link)'); 319 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'cid-link', path: [] }]); 320 + } 321 + 322 + { 323 + const result = v.safeParse(schema, { $link: '' }); 324 + 325 + assert(!result.ok, `expected validation issue`); 326 + expect(result.message).toBe('invalid_type at . (expected cid-link)'); 327 + expect(result.issues).toEqual([{ code: 'invalid_type', expected: 'cid-link', path: [] }]); 328 + } 329 + }); 330 + }); 331 + 332 + it(`validates nullable type`, () => { 333 + const schema = v.nullable(v.string()); 334 + 335 + expect(v.is(schema, 'abc')).toBe(true); 336 + expect(v.is(schema, null)).toBe(true); 337 + 338 + expect(v.is(schema, undefined)).toBe(false); 339 + expect(v.is(schema, 123)).toBe(false); 340 + }); 341 + 342 + it(`validates optional type`, () => { 343 + { 344 + const schema = v.optional(v.integer()); 345 + 346 + expect(v.is(schema, 123)).toBe(true); 347 + expect(v.parse(schema, undefined)).toBe(undefined); 348 + 349 + expect(v.is(schema, null)).toBe(false); 350 + expect(v.is(schema, 'abc')).toBe(false); 351 + } 352 + 353 + { 354 + const schema = v.optional(v.integer(), 234); 355 + 356 + expect(v.is(schema, 123)).toBe(true); 357 + expect(v.parse(schema, undefined)).toBe(234); 358 + 359 + expect(v.is(schema, null)).toBe(false); 360 + expect(v.is(schema, 'abc')).toBe(false); 361 + } 362 + 363 + { 364 + let count = 0; 365 + 366 + const schema = v.optional(v.integer(), () => ++count); 367 + 368 + expect(v.is(schema, 123)).toBe(true); 369 + expect(v.parse(schema, undefined)).toBe(1); 370 + expect(v.parse(schema, undefined)).toBe(2); 371 + 372 + expect(v.is(schema, null)).toBe(false); 373 + expect(v.is(schema, 'abc')).toBe(false); 374 + } 375 + }); 376 + 377 + describe(`complex types`, () => { 378 + // currently hardwired to v.literal() 379 + it.todo(`validates token type`, () => {}); 380 + 381 + it(`validates array type`, () => { 382 + { 383 + const schema = v.array(v.literalEnum(['alice', 'bob', 'mallory', 'frank'])); 384 + 385 + expect(v.is(schema, ['alice', 'bob', 'mallory'])).toBe(true); 386 + expect(v.is(schema, [])).toBe(true); 387 + 388 + expect(v.is(schema, ['alice', 'grace', 'bob'])).toBe(false); 389 + 390 + expect(v.is(schema, 123)).toBe(false); 391 + 392 + { 393 + const result = v.safeParse(schema, ['alice', 'olivia', 'walter']); 394 + 395 + assert(!result.ok, `expected validation issue`); 396 + expect(result.message).toBe( 397 + 'invalid_literal at .1 (expected "alice", "bob", "mallory" or "frank") (+1 other issue(s))', 398 + ); 399 + expect(result.issues).toEqual([ 400 + { code: 'invalid_literal', expected: ['alice', 'bob', 'mallory', 'frank'], path: [1] }, 401 + { code: 'invalid_literal', expected: ['alice', 'bob', 'mallory', 'frank'], path: [2] }, 402 + ]); 403 + } 404 + } 405 + 406 + { 407 + const schema = v.array(v.optional(v.string(), 'erin')); 408 + 409 + expect(v.parse(schema, ['alice', undefined])).toEqual(['alice', 'erin']); 410 + } 411 + }); 412 + 413 + describe(`validates object type`, () => { 414 + it(`with eval`, () => { 415 + using _mock = vi.spyOn(allowsEval, 'value', 'get').mockReturnValue(true); 416 + 417 + { 418 + const schema = v.object({ 419 + required: v.literal('alice'), 420 + 421 + unfilled: v.optional(v.string()), 422 + filled: v.optional(v.string()), 423 + 424 + defaulted: v.optional(v.string(), 'defaulted'), 425 + defaultedFn: v.optional(v.string(), () => 'defaulted-fn'), 426 + }); 427 + 428 + expect( 429 + v.parse(schema, { 430 + required: 'alice', 431 + filled: 'filled', 432 + unspecified: 'unspecified', 433 + }), 434 + ).toEqual({ 435 + required: 'alice', 436 + filled: 'filled', 437 + defaulted: 'defaulted', 438 + defaultedFn: 'defaulted-fn', 439 + unspecified: 'unspecified', 440 + }); 441 + 442 + expect( 443 + v.parse(schema, { 444 + required: 'alice', 445 + defaulted: 'filled-default', 446 + defaultedFn: 'filled-default-fn', 447 + }), 448 + ).toEqual({ 449 + required: 'alice', 450 + defaulted: 'filled-default', 451 + defaultedFn: 'filled-default-fn', 452 + }); 453 + 454 + expect(v.is(schema, {})).toBe(false); 455 + expect(v.is(schema, 123)).toBe(false); 456 + 457 + { 458 + const result = v.safeParse(schema, { required: 'bob' }); 459 + 460 + assert(!result.ok, `expected validation issue`); 461 + expect(result.message).toBe('invalid_literal at .required (expected "alice")'); 462 + expect(result.issues).toEqual([ 463 + { code: 'invalid_literal', expected: ['alice'], path: ['required'] }, 464 + ]); 465 + } 466 + } 467 + 468 + { 469 + const schema = v.object({ 470 + ['__proto__']: v.literal('bob'), 471 + }); 472 + 473 + expect(v.parse(schema, { ['__proto__']: 'bob' })).toEqual({ ['__proto__']: 'bob' }); 474 + 475 + expect(v.is(schema, { ['__proto__']: 'alice' })).toBe(false); 476 + } 477 + 478 + { 479 + const schema = v.object({ 480 + value: v.integer(), 481 + get next() { 482 + return v.optional(schema); 483 + }, 484 + }); 485 + 486 + expect(v.is(schema, { value: 1 })).toBe(true); 487 + expect(v.is(schema, { value: 1, next: { value: 2 } })).toBe(true); 488 + 489 + v.parse(schema, { value: 1, next: { value: 2, next: { value: 3 } } }); 490 + } 491 + 492 + { 493 + const addressSchema = v.object({ 494 + street: v.string(), 495 + city: v.string(), 496 + zipCode: v.optional(v.string()), 497 + }); 498 + 499 + const personSchema = v.object({ 500 + name: v.string(), 501 + age: v.integer(), 502 + address: addressSchema, 503 + addresses: v.array(addressSchema), 504 + }); 505 + 506 + v.parse(personSchema, { 507 + name: 'John Doe', 508 + age: 30, 509 + address: { 510 + street: '123 Main St', 511 + city: 'Anytown', 512 + zipCode: '12345', 513 + }, 514 + addresses: [ 515 + { 516 + street: '456 Oak Ave', 517 + city: 'Other City', 518 + }, 519 + { 520 + street: '789 Pine Rd', 521 + city: 'Another City', 522 + zipCode: '67890', 523 + }, 524 + ], 525 + }); 526 + } 109 527 }); 528 + 529 + it(`without eval`, () => { 530 + using _mock = vi.spyOn(allowsEval, 'value', 'get').mockReturnValue(false); 531 + 532 + { 533 + const schema = v.object({ 534 + required: v.literal('alice'), 535 + 536 + unfilled: v.optional(v.string()), 537 + filled: v.optional(v.string()), 538 + 539 + defaulted: v.optional(v.string(), 'defaulted'), 540 + defaultedFn: v.optional(v.string(), () => 'defaulted-fn'), 541 + }); 542 + 543 + expect( 544 + v.parse(schema, { 545 + required: 'alice', 546 + filled: 'filled', 547 + unspecified: 'unspecified', 548 + }), 549 + ).toEqual({ 550 + required: 'alice', 551 + filled: 'filled', 552 + defaulted: 'defaulted', 553 + defaultedFn: 'defaulted-fn', 554 + unspecified: 'unspecified', 555 + }); 556 + 557 + expect( 558 + v.parse(schema, { 559 + required: 'alice', 560 + defaulted: 'filled-default', 561 + defaultedFn: 'filled-default-fn', 562 + }), 563 + ).toEqual({ 564 + required: 'alice', 565 + defaulted: 'filled-default', 566 + defaultedFn: 'filled-default-fn', 567 + }); 568 + 569 + expect(v.is(schema, {})).toBe(false); 570 + expect(v.is(schema, 123)).toBe(false); 571 + 572 + { 573 + const result = v.safeParse(schema, { required: 'bob' }); 574 + 575 + assert(!result.ok, `expected validation issue`); 576 + expect(result.message).toBe('invalid_literal at .required (expected "alice")'); 577 + expect(result.issues).toEqual([ 578 + { code: 'invalid_literal', expected: ['alice'], path: ['required'] }, 579 + ]); 580 + } 581 + } 582 + 583 + { 584 + const schema = v.object({ 585 + ['__proto__']: v.literal('bob'), 586 + }); 587 + 588 + expect(v.parse(schema, { ['__proto__']: 'bob' })).toEqual({ ['__proto__']: 'bob' }); 589 + 590 + expect(v.is(schema, { ['__proto__']: 'alice' })).toBe(false); 591 + } 592 + }); 593 + }); 594 + 595 + describe(`validates record type`, () => { 596 + it(`with eval`, () => { 597 + using _mock = vi.spyOn(allowsEval, 'value', 'get').mockReturnValue(true); 598 + 599 + const schema = v.record( 600 + v.tidString(), 601 + v.object({ 602 + $type: v.literal('com.example.kitchen'), 603 + createdAt: v.datetimeString(), 604 + }), 605 + ); 606 + 607 + expect(v.is(schema, { $type: 'com.example.kitchen', createdAt: new Date().toISOString() })).toBe(true); 608 + }); 609 + 610 + it(`without eval`, () => { 611 + using _mock = vi.spyOn(allowsEval, 'value', 'get').mockReturnValue(false); 612 + 613 + const schema = v.record( 614 + v.tidString(), 615 + v.object({ 616 + $type: v.literal('com.example.kitchen'), 617 + createdAt: v.datetimeString(), 618 + }), 619 + ); 620 + 621 + expect(v.is(schema, { $type: 'com.example.kitchen', createdAt: new Date().toISOString() })).toBe(true); 622 + }); 623 + }); 624 + 625 + it(`validates variant type`, () => { 626 + { 627 + const schema = v.variant( 628 + [ 629 + v.object({ 630 + $type: v.literal('label'), 631 + identifier: v.string(), 632 + preference: v.literalEnum(['allow', 'hide', 'warn']), 633 + }), 634 + 635 + v.object({ 636 + $type: v.literal('adultContent'), 637 + enabled: v.optional(v.boolean(), false), 638 + }), 639 + ], 640 + false, 641 + ); 642 + 643 + expect(v.is(schema, { $type: 'label', identifier: 'rude', preference: 'allow' })).toBe(true); 644 + expect(v.is(schema, { $type: 'adultContent' })).toBe(true); 645 + expect(v.is(schema, { $type: 'adultContent', enabled: false })).toBe(true); 646 + 647 + expect(v.is(schema, { $type: 'unknown', hello: 'world' })).toBe(true); 648 + 649 + expect(v.is(schema, 123)).toBe(false); 650 + expect(v.is(schema, {})).toBe(false); 651 + expect(v.is(schema, { $type: 123 })).toBe(false); 652 + expect(v.is(schema, { $type: 'adultContent', enabled: 123 })).toBe(false); 653 + } 654 + 655 + { 656 + const schema = v.variant( 657 + [ 658 + v.object({ 659 + $type: v.literal('label'), 660 + identifier: v.string(), 661 + preference: v.literalEnum(['allow', 'hide', 'warn']), 662 + }), 663 + 664 + v.object({ 665 + $type: v.literal('adultContent'), 666 + enabled: v.optional(v.boolean(), false), 667 + }), 668 + ], 669 + true, 670 + ); 671 + 672 + expect(v.is(schema, { $type: 'label', identifier: 'rude', preference: 'allow' })).toBe(true); 673 + expect(v.is(schema, { $type: 'adultContent' })).toBe(true); 674 + expect(v.is(schema, { $type: 'adultContent', enabled: false })).toBe(true); 675 + 676 + expect(v.is(schema, { $type: 'unknown', hello: 'world' })).toBe(false); 677 + 678 + expect(v.is(schema, 123)).toBe(false); 679 + expect(v.is(schema, {})).toBe(false); 680 + expect(v.is(schema, { $type: 123 })).toBe(false); 681 + expect(v.is(schema, { $type: 'adultContent', enabled: 123 })).toBe(false); 682 + } 683 + }); 684 + }); 685 + 686 + describe(`constraints`, () => { 687 + describe(`integer`, () => { 688 + it(`constrains integer range`, () => { 689 + const schema = v.constrain(v.integer(), [v.integerRange(6, 12)]); 690 + 691 + expect(v.is(schema, 9)).toBe(true); 692 + 693 + expect(v.is(schema, 3)).toBe(false); 694 + expect(v.is(schema, 15)).toBe(false); 695 + 696 + { 697 + const result = v.safeParse(schema, 15); 698 + 699 + assert(!result.ok, `expected validation issue`); 700 + expect(result.message).toBe('invalid_integer_range at . (expected an integer between 6 and 12)'); 701 + expect(result.issues).toEqual([{ code: 'invalid_integer_range', min: 6, max: 12, path: [] }]); 702 + } 703 + }); 704 + }); 705 + 706 + describe(`string`, () => { 707 + it(`constrains string UTF-8 length`, () => { 708 + const schema = v.constrain(v.string(), [v.stringLength(6, 12)]); 709 + 710 + expect(v.is(schema, 'a'.repeat(9)), `length: 9`).toBe(true); 711 + 712 + expect(v.is(schema, 'b'.repeat(3)), `length: 3`).toBe(false); 713 + expect(v.is(schema, 'c'.repeat(15)), `length: 15`).toBe(false); 714 + 715 + // 'café' = 4 UTF-16 chars, 5 UTF-8 bytes - should pass (5 bytes < 12 max) 716 + expect(v.is(schema, 'café'), `café: 4 UTF-16, 5 UTF-8`).toBe(false); // 5 bytes < 6 min 717 + 718 + // '𝕳𝖊𝖑𝖑𝖔' = 10 UTF-16 chars, 20 UTF-8 bytes - should fail (20 bytes > 12 max) 719 + expect(v.is(schema, '𝕳𝖊𝖑𝖑𝖔'), `math bold: 10 UTF-16, 20 UTF-8`).toBe(false); 720 + 721 + // 'नमस्ते' = 6 UTF-16 chars, 18 UTF-8 bytes - should fail (18 bytes > 12 max) 722 + expect(v.is(schema, 'नमस्ते'), `devanagari: 6 UTF-16, 18 UTF-8`).toBe(false); 723 + 724 + // 'ééé' = 3 UTF-16 chars, 6 UTF-8 bytes - should pass 725 + expect(v.is(schema, 'ééé'), `accented: 3 UTF-16, 6 UTF-8`).toBe(true); 726 + 727 + { 728 + const result = v.safeParse(schema, 'c'.repeat(15)); 729 + 730 + assert(!result.ok, `expected validation issue`); 731 + expect(result.message).toBe( 732 + 'invalid_string_length at . (expected a string between 6 and 12 character(s))', 733 + ); 734 + expect(result.issues).toEqual([ 735 + { code: 'invalid_string_length', minLength: 6, maxLength: 12, path: [] }, 736 + ]); 737 + } 738 + 739 + { 740 + const result = v.safeParse(schema, '𝕳𝖊𝖑𝖑𝖔'); 741 + 742 + assert(!result.ok, `expected validation issue`); 743 + expect(result.message).toBe( 744 + 'invalid_string_length at . (expected a string between 6 and 12 character(s))', 745 + ); 746 + expect(result.issues).toEqual([ 747 + { code: 'invalid_string_length', minLength: 6, maxLength: 12, path: [] }, 748 + ]); 749 + } 750 + }); 751 + 752 + it(`constrains string grapheme length`, () => { 753 + { 754 + const schema = v.constrain(v.string(), [v.stringGraphemes(6, 12)]); 755 + 756 + expect(v.is(schema, 'a'.repeat(9)), `length: 9`).toBe(true); 757 + 758 + expect(v.is(schema, 'b'.repeat(3)), `length: 3`).toBe(false); 759 + expect(v.is(schema, 'c'.repeat(15)), `length: 15`).toBe(false); 760 + 761 + // '👨‍👩‍👧‍👦' = 11 UTF-16 chars, 1 grapheme - should fail (1 grapheme < 6 min) 762 + expect(v.is(schema, '👨‍👩‍👧‍👦'), `family emoji: 11 UTF-16, 1 grapheme`).toBe(false); 763 + 764 + // '🏳️‍🌈' = 6 UTF-16 chars, 1 grapheme - should fail (1 grapheme < 6 min) 765 + expect(v.is(schema, '🏳️‍🌈'), `rainbow flag: 6 UTF-16, 1 grapheme`).toBe(false); 766 + 767 + // '🇺🇸🇺🇸🇺🇸🇺🇸🇺🇸🇺🇸🇺🇸' = 28 UTF-16 chars, 7 graphemes - should pass (7 graphemes in 6-12 range) 768 + expect(v.is(schema, '🇺🇸🇺🇸🇺🇸🇺🇸🇺🇸🇺🇸🇺🇸'), `flag emojis: 28 UTF-16, 7 graphemes`).toBe(true); 769 + 770 + // 'नमस्ते नमस्ते नमस्ते' = 20 UTF-16 chars, 11 graphemes - should pass (11 graphemes in 6-12 range) 771 + expect(v.is(schema, 'नमस्ते नमस्ते नमस्ते'), `devanagari: 20 UTF-16, 11 graphemes`).toBe(true); 772 + 773 + // 'e\u0301'.repeat(7) = 14 UTF-16 chars, 7 graphemes - should pass (7 graphemes in 6-12 range) 774 + expect(v.is(schema, 'e\u0301'.repeat(7)), `decomposed accents: 14 UTF-16, 7 graphemes`).toBe(true); 775 + 776 + // '👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦👨‍👩‍👧‍👦' = 66 UTF-16 chars, 6 graphemes 777 + expect(v.is(schema, '👨‍👩‍👧‍👦'.repeat(6)), `family emojis: 66 UTF-16, 6 graphemes`).toBe(true); 778 + 779 + { 780 + const result = v.safeParse(schema, 'c'.repeat(15)); 781 + 782 + assert(!result.ok, `expected validation issue`); 783 + expect(result.message).toBe( 784 + 'invalid_string_graphemes at . (expected a string between 6 and 12 grapheme(s))', 785 + ); 786 + expect(result.issues).toEqual([ 787 + { code: 'invalid_string_graphemes', minGraphemes: 6, maxGraphemes: 12, path: [] }, 788 + ]); 789 + } 790 + 791 + { 792 + const result = v.safeParse(schema, '👨‍👩‍👧‍👦'); // 11 UTF-16 chars, 1 grapheme 793 + 794 + assert(!result.ok, `expected validation issue`); 795 + expect(result.message).toBe( 796 + 'invalid_string_graphemes at . (expected a string between 6 and 12 grapheme(s))', 797 + ); 798 + expect(result.issues).toEqual([ 799 + { code: 'invalid_string_graphemes', minGraphemes: 6, maxGraphemes: 12, path: [] }, 800 + ]); 801 + } 802 + 803 + { 804 + const result = v.safeParse(schema, '👨‍👩‍👧‍👦'.repeat(15)); // 165 UTF-16 chars, 15 graphemes 805 + 806 + assert(!result.ok, `expected validation issue`); 807 + expect(result.message).toBe( 808 + 'invalid_string_graphemes at . (expected a string between 6 and 12 grapheme(s))', 809 + ); 810 + expect(result.issues).toEqual([ 811 + { code: 'invalid_string_graphemes', minGraphemes: 6, maxGraphemes: 12, path: [] }, 812 + ]); 813 + } 814 + } 815 + 816 + { 817 + const schema = v.constrain(v.string(), [v.stringGraphemes(0, 15)]); 818 + 819 + expect(v.is(schema, '👨‍👩‍👧‍👦'), `family emoji: 11 UTF-16 <= 15 max, 1 grapheme`).toBe(true); 820 + expect(v.is(schema, '🏳️‍🌈'), `rainbow flag: 6 UTF-16 <= 15 max, 1 grapheme`).toBe(true); 821 + expect(v.is(schema, 'a'.repeat(15)), `15 ASCII chars: 15 UTF-16 <= 15 max`).toBe(true); 822 + } 823 + }); 824 + }); 825 + 826 + describe(`bytes`, () => { 827 + it(`constrains byte size`, () => { 828 + const testCases = [ 829 + { base64: '', expectedSize: 0 }, // "" 830 + { base64: 'YQ==', expectedSize: 1 }, // "a" 831 + { base64: 'YWI=', expectedSize: 2 }, // "ab" 832 + { base64: 'YWJj', expectedSize: 3 }, // "abc" 833 + { base64: 'YWJjZA==', expectedSize: 4 }, // "abcd" 834 + { base64: 'aGVsbG8=', expectedSize: 5 }, // "hello" 835 + { base64: 'MTIzNDU2', expectedSize: 6 }, // "123456" 836 + { base64: 'dGVzdGluZw==', expectedSize: 7 }, // "testing" 837 + { base64: 'MTIzNDU2Nzg=', expectedSize: 8 }, // "12345678" 838 + { base64: 'dGVzdGluZzE=', expectedSize: 8 }, // "testing1" 839 + { base64: 'dGVzdGluZzEy', expectedSize: 9 }, // "testing12" 840 + { base64: 'dGVzdGluZzEyMw==', expectedSize: 10 }, // "testing123" 841 + { base64: 'aGVsbG8gd29ybGQ=', expectedSize: 11 }, // "hello world" 842 + { base64: 'dGhpcyBpcyBhIGxvbmcgc3RyaW5n', expectedSize: 21 }, // "this is a long string" 843 + ]; 844 + 845 + for (const { base64, expectedSize } of testCases) { 846 + const exact = v.constrain(v.bytes(), [v.bytesSize(expectedSize, expectedSize)]); 847 + 848 + const json = { $bytes: base64 }; 849 + const lex = toBytes(fromBase64(base64)); 850 + 851 + expect(v.is(exact, json), `${base64} == ${expectedSize}`).toBe(true); 852 + expect(v.is(exact, lex), `${base64} == ${expectedSize}`).toBe(true); 853 + 854 + // test that one size different fails 855 + if (expectedSize > 0) { 856 + const plus = v.constrain(v.bytes(), [v.bytesSize(expectedSize + 1, expectedSize + 1)]); 857 + expect(v.is(plus, json), `${base64} != ${expectedSize + 1}`).toBe(false); 858 + expect(v.is(plus, lex), `${base64} != ${expectedSize + 1}`).toBe(false); 859 + 860 + const minus = v.constrain(v.bytes(), [v.bytesSize(expectedSize - 1, expectedSize - 1)]); 861 + expect(v.is(minus, json), `${base64} != ${expectedSize - 1}`).toBe(false); 862 + expect(v.is(minus, lex), `${base64} != ${expectedSize + 1}`).toBe(false); 863 + } 864 + } 865 + 866 + // test error messages 867 + { 868 + const schema = v.constrain(v.bytes(), [v.bytesSize(6, 12)]); 869 + const result = v.safeParse(schema, { $bytes: 'YQ==' }); // "a" = 1 byte 870 + 871 + assert(!result.ok, `expected validation issue`); 872 + expect(result.message).toBe( 873 + 'invalid_bytes_size at . (expected a byte array between 6 and 12 byte(s))', 874 + ); 875 + expect(result.issues).toEqual([{ code: 'invalid_bytes_size', minSize: 6, maxSize: 12, path: [] }]); 876 + } 877 + 878 + { 879 + const schema = v.constrain(v.bytes(), [v.bytesSize(6, 12)]); 880 + const result = v.safeParse(schema, { $bytes: 'dGhpcyBpcyBhIGxvbmcgc3RyaW5n' }); // 21 bytes 881 + 882 + assert(!result.ok, `expected validation issue`); 883 + expect(result.message).toBe( 884 + 'invalid_bytes_size at . (expected a byte array between 6 and 12 byte(s))', 885 + ); 886 + expect(result.issues).toEqual([{ code: 'invalid_bytes_size', minSize: 6, maxSize: 12, path: [] }]); 887 + } 888 + }); 889 + }); 890 + 891 + describe(`array`, () => { 892 + it(`constrains array length`, () => { 893 + const schema = v.constrain(v.array(v.string()), [v.arrayLength(2, 3)]); 894 + 895 + expect(v.is(schema, ['alice', 'bob', 'mallory'])).toBe(true); 896 + 897 + expect(v.is(schema, ['alice'])).toBe(false); 898 + expect(v.is(schema, ['alice', 'bob', 'mallory', 'frank'])).toBe(false); 899 + 900 + { 901 + const result = v.safeParse(schema, ['alice', 'bob', 'mallory', 'frank']); 902 + 903 + assert(!result.ok, `expected validation issue`); 904 + expect(result.message).toBe('invalid_array_length at . (expected an array between 2 and 3 item(s))'); 905 + expect(result.issues).toEqual([ 906 + { code: 'invalid_array_length', maxLength: 3, minLength: 2, path: [] }, 907 + ]); 908 + } 909 + }); 910 + }); 911 + }); 912 + 913 + describe(`string format types`, () => { 914 + it(`validates actorIdentifierString`, () => { 915 + const schema = v.actorIdentifierString(); 916 + 917 + expect(v.is(schema, 'alice.bsky.social')).toBe(true); 918 + expect(v.is(schema, 'did:web:example.com')).toBe(true); 919 + expect(v.is(schema, 'invalid-identifier')).toBe(false); 920 + 921 + expect(v.is(schema, null)).toBe(false); 922 + 923 + { 924 + const result = v.safeParse(schema, 'invalid'); 925 + 926 + assert(!result.ok, `expected validation issue`); 927 + expect(result.message).toBe('invalid_string_format at . (expected a at-identifier formatted string)'); 928 + expect(result.issues).toEqual([{ code: 'invalid_string_format', expected: 'at-identifier', path: [] }]); 929 + } 930 + }); 931 + 932 + it(`validates resourceUriString`, () => { 933 + const schema = v.resourceUriString(); 934 + 935 + expect(v.is(schema, 'at://did:plc:asdf123/com.atproto.feed.post/record')).toBe(true); 936 + expect(v.is(schema, 'at://user.bsky.social')).toBe(true); 937 + 938 + expect(v.is(schema, 'invalid-uri')).toBe(false); 939 + 940 + expect(v.is(schema, null)).toBe(false); 941 + }); 942 + 943 + it(`validates cidString`, () => { 944 + const schema = v.cidString(); 945 + 946 + expect(v.is(schema, 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a')).toBe(true); 947 + 948 + expect(v.is(schema, 'invalid-cid')).toBe(false); 949 + 950 + expect(v.is(schema, null)).toBe(false); 951 + }); 952 + 953 + it(`validates datetimeString`, () => { 954 + const schema = v.datetimeString(); 955 + 956 + expect(v.is(schema, '1985-04-12T23:20:50.123Z')).toBe(true); 957 + expect(v.is(schema, '2000-01-01T00:00:00.000Z')).toBe(true); 958 + 959 + expect(v.is(schema, 'invalid-datetime')).toBe(false); 960 + 961 + expect(v.is(schema, null)).toBe(false); 962 + }); 963 + 964 + it(`validates didString`, () => { 965 + const schema = v.didString(); 966 + 967 + expect(v.is(schema, 'did:web:example.com')).toBe(true); 968 + expect(v.is(schema, 'did:plc:7iza6de2dwap2sbkpav7c6c6')).toBe(true); 969 + 970 + expect(v.is(schema, 'invalid-did')).toBe(false); 971 + 972 + expect(v.is(schema, null)).toBe(false); 973 + }); 974 + 975 + it(`validates handleString`, () => { 976 + const schema = v.handleString(); 977 + 978 + expect(v.is(schema, 'alice.bsky.social')).toBe(true); 979 + expect(v.is(schema, 'john.test')).toBe(true); 980 + 981 + expect(v.is(schema, 'invalid-handle')).toBe(false); 982 + 983 + expect(v.is(schema, null)).toBe(false); 984 + }); 985 + 986 + it(`validates languageCodeString`, () => { 987 + const schema = v.languageCodeString(); 988 + 989 + expect(v.is(schema, 'en')).toBe(true); 990 + expect(v.is(schema, 'en-GB')).toBe(true); 991 + expect(v.is(schema, 'pt-BR')).toBe(true); 992 + 993 + expect(v.is(schema, 'j')).toBe(false); 994 + 995 + expect(v.is(schema, null)).toBe(false); 996 + }); 997 + 998 + it(`validates nsidString`, () => { 999 + const schema = v.nsidString(); 1000 + 1001 + expect(v.is(schema, 'com.example.fooBar')).toBe(true); 1002 + expect(v.is(schema, 'net.users.bob.ping')).toBe(true); 1003 + 1004 + expect(v.is(schema, 'invalid-nsid')).toBe(false); 1005 + 1006 + expect(v.is(schema, null)).toBe(false); 1007 + }); 1008 + 1009 + it(`validates recordKeyString`, () => { 1010 + const schema = v.recordKeyString(); 1011 + 1012 + expect(v.is(schema, 'self')).toBe(true); 1013 + expect(v.is(schema, 'example.com')).toBe(true); 1014 + expect(v.is(schema, 'literal:self')).toBe(true); 1015 + 1016 + expect(v.is(schema, 'invalid/key')).toBe(false); 1017 + 1018 + expect(v.is(schema, null)).toBe(false); 1019 + }); 1020 + 1021 + it(`validates tidString`, () => { 1022 + const schema = v.tidString(); 1023 + 1024 + expect(v.is(schema, '3jzfcijpj2z2a')).toBe(true); 1025 + expect(v.is(schema, '7777777777777')).toBe(true); 1026 + 1027 + expect(v.is(schema, 'invalid-tid')).toBe(false); 1028 + 1029 + expect(v.is(schema, null)).toBe(false); 1030 + }); 1031 + 1032 + it(`validates genericUriString`, () => { 1033 + const schema = v.genericUriString(); 1034 + 1035 + expect(v.is(schema, 'https://example.com')).toBe(true); 1036 + expect(v.is(schema, 'dns:example.com')).toBe(true); 1037 + expect(v.is(schema, 'at://handle.example.com/nsid/rkey')).toBe(true); 1038 + 1039 + expect(v.is(schema, 'invalid-uri')).toBe(false); 1040 + 1041 + expect(v.is(schema, null)).toBe(false); 110 1042 }); 111 1043 });
+2
packages/lexicons/lexicons/package.json
··· 28 28 "prepublish": "rm -rf dist; pnpm run build" 29 29 }, 30 30 "devDependencies": { 31 + "@atcute/cbor": "workspace:^", 32 + "@atcute/multibase": "workspace:^", 31 33 "@vitest/coverage-v8": "^3.2.4", 32 34 "vitest": "^3.2.4" 33 35 },
+6
pnpm-lock.yaml
··· 492 492 specifier: ^1.2.2 493 493 version: 1.2.2 494 494 devDependencies: 495 + '@atcute/cbor': 496 + specifier: workspace:^ 497 + version: link:../../utilities/cbor 498 + '@atcute/multibase': 499 + specifier: workspace:^ 500 + version: link:../../utilities/multibase 495 501 '@vitest/coverage-v8': 496 502 specifier: ^3.2.4 497 503 version: 3.2.4(vitest@3.2.4(@types/node@22.15.29)(yaml@2.8.0))