import { CID } from 'multiformats/cid' import { LexiconDoc, Lexicons, parseLexiconDoc } from '../src/index' import LexiconDocs from './_scaffolds/lexicons' describe('Lexicons collection', () => { const lex = new Lexicons(LexiconDocs) it('Adds schemas', () => { expect(() => lex.add(LexiconDocs[0])).toThrow() }) it('Correctly references all definitions', () => { expect(lex.getDef('com.example.kitchenSink')).toEqual( LexiconDocs[0].defs.main, ) expect(lex.getDef('lex:com.example.kitchenSink')).toEqual( LexiconDocs[0].defs.main, ) expect(lex.getDef('com.example.kitchenSink#main')).toEqual( LexiconDocs[0].defs.main, ) expect(lex.getDef('lex:com.example.kitchenSink#main')).toEqual( LexiconDocs[0].defs.main, ) expect(lex.getDef('com.example.kitchenSink#object')).toEqual( LexiconDocs[0].defs.object, ) expect(lex.getDef('lex:com.example.kitchenSink#object')).toEqual( LexiconDocs[0].defs.object, ) }) }) describe('General validation', () => { const lex = new Lexicons(LexiconDocs) it('Validates records correctly', () => { { const res = lex.validate('com.example.kitchenSink', { $type: 'com.example.kitchenSink', object: { object: { boolean: true }, array: ['one', 'two'], boolean: true, integer: 123, string: 'string', }, array: ['one', 'two'], boolean: true, integer: 123, string: 'string', datetime: new Date().toISOString(), atUri: 'at://did:web:example.com/com.example.test/self', did: 'did:web:example.com', cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', bytes: new Uint8Array([0, 1, 2, 3]), cidLink: CID.parse( 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', ), }) expect(res.success).toBe(true) } { const res = lex.validate('com.example.kitchenSink', {}) expect(res.success).toBe(false) if (res.success) throw new Error('Asserted') expect(res.error?.message).toBe('Record must have the property "object"') } }) it('Validates objects correctly', () => { { const res = lex.validate('com.example.kitchenSink#object', { object: { boolean: true }, array: ['one', 'two'], boolean: true, integer: 123, string: 'string', }) expect(res.success).toBe(true) } { const res = lex.validate('com.example.kitchenSink#object', {}) expect(res.success).toBe(false) if (res.success) throw new Error('Asserted') expect(res.error?.message).toBe('Object must have the property "object"') } }) it('fails when a required property is missing', () => { const schema = { lexicon: 1, id: 'com.example.kitchenSink', defs: { test: { type: 'object', required: ['foo'], properties: {}, }, }, } expect(() => { parseLexiconDoc(schema) }).toThrow('Required field \\"foo\\" not defined') }) it('fails when unknown fields are present', () => { const schema = { lexicon: 1, id: 'com.example.unknownFields', defs: { test: { type: 'object', foo: 3, }, }, } expect(() => { parseLexiconDoc(schema) }).toThrow("Unrecognized key(s) in object: 'foo'") }) it('fails lexicon parsing when uri is invalid', () => { const schema: LexiconDoc = { lexicon: 1, id: 'com.example.invalidUri', defs: { main: { type: 'object', properties: { test: { type: 'ref', ref: 'com.example.invalid#test#test' }, }, }, }, } expect(() => { new Lexicons([schema]) }).toThrow('Uri can only have one hash segment') }) it('fails validation when ref uri has multiple hash segments', () => { const schema: LexiconDoc = { lexicon: 1, id: 'com.example.invalidUri', defs: { main: { type: 'object', properties: { test: { type: 'integer' }, }, }, object: { type: 'object', required: ['test'], properties: { test: { type: 'union', refs: ['com.example.invalidUri'], }, }, }, }, } const lexicons = new Lexicons([schema]) expect(() => { lexicons.validate('com.example.invalidUri#object', { test: { $type: 'com.example.invalidUri#main#main', test: 123, }, }) }).toThrow('Uri can only have one hash segment') }) it('union handles both implicit and explicit #main', () => { const schemas: LexiconDoc[] = [ { lexicon: 1, id: 'com.example.implicitMain', defs: { main: { type: 'object', required: ['test'], properties: { test: { type: 'string' }, }, }, }, }, { lexicon: 1, id: 'com.example.testImplicitMain', defs: { main: { type: 'object', required: ['union'], properties: { union: { type: 'union', refs: ['com.example.implicitMain'], }, }, }, }, }, { lexicon: 1, id: 'com.example.testExplicitMain', defs: { main: { type: 'object', required: ['union'], properties: { union: { type: 'union', refs: ['com.example.implicitMain#main'], }, }, }, }, }, ] const lexicon = new Lexicons(schemas) let result = lexicon.validate('com.example.testImplicitMain', { union: { $type: 'com.example.implicitMain', test: 123, }, }) expect(result.success).toBeFalsy() expect(result['error']?.message).toBe('Object/union/test must be a string') result = lexicon.validate('com.example.testImplicitMain', { union: { $type: 'com.example.implicitMain#main', test: 123, }, }) expect(result.success).toBeFalsy() expect(result['error']?.message).toBe('Object/union/test must be a string') result = lexicon.validate('com.example.testExplicitMain', { union: { $type: 'com.example.implicitMain', test: 123, }, }) expect(result.success).toBeFalsy() expect(result['error']?.message).toBe('Object/union/test must be a string') result = lexicon.validate('com.example.testExplicitMain', { union: { $type: 'com.example.implicitMain#main', test: 123, }, }) expect(result.success).toBeFalsy() expect(result['error']?.message).toBe('Object/union/test must be a string') }) }) describe('Record validation', () => { const lex = new Lexicons(LexiconDocs) const passingSink = { $type: 'com.example.kitchenSink', object: { object: { boolean: true }, array: ['one', 'two'], boolean: true, integer: 123, string: 'string', }, array: ['one', 'two'], boolean: true, integer: 123, string: 'string', bytes: new Uint8Array([0, 1, 2, 3]), cidLink: CID.parse( 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', ), } it('Passes valid schemas', () => { lex.assertValidRecord('com.example.kitchenSink', passingSink) }) it('Fails invalid input types', () => { expect(() => lex.assertValidRecord('com.example.kitchenSink', undefined), ).toThrow('Record must be an object') expect(() => lex.assertValidRecord('com.example.kitchenSink', 1234), ).toThrow('Record must be an object') expect(() => lex.assertValidRecord('com.example.kitchenSink', 'string'), ).toThrow('Record must be an object') }) it('Fails incorrect $type', () => { expect(() => lex.assertValidRecord('com.example.kitchenSink', {})).toThrow( 'Record/$type must be a string', ) expect(() => lex.assertValidRecord('com.example.kitchenSink', { $type: 'foo' }), ).toThrow('Invalid $type: must be lex:com.example.kitchenSink, got foo') }) it('Fails missing required', () => { expect(() => lex.assertValidRecord('com.example.kitchenSink', { $type: 'com.example.kitchenSink', array: ['one', 'two'], boolean: true, integer: 123, string: 'string', datetime: new Date().toISOString(), atUri: 'at://did:web:example.com/com.example.test/self', did: 'did:web:example.com', cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', bytes: new Uint8Array([0, 1, 2, 3]), cidLink: CID.parse( 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', ), }), ).toThrow('Record must have the property "object"') expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, object: undefined, }), ).toThrow('Record must have the property "object"') }) it('Fails incorrect types', () => { expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, object: { ...passingSink.object, object: { boolean: '1234' }, }, }), ).toThrow('Record/object/object/boolean must be a boolean') expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, object: true, }), ).toThrow('Record/object must be an object') expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, array: 1234, }), ).toThrow('Record/array must be an array') expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, integer: true, }), ).toThrow('Record/integer must be an integer') expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, string: {}, }), ).toThrow('Record/string must be a string') expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, bytes: 1234, }), ).toThrow('Record/bytes must be a byte array') expect(() => lex.assertValidRecord('com.example.kitchenSink', { ...passingSink, cidLink: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', }), ).toThrow('Record/cidLink must be a CID') }) it('Handles optional properties correctly', () => { lex.assertValidRecord('com.example.optional', { $type: 'com.example.optional', }) }) it('Handles default properties correctly', () => { const result = lex.assertValidRecord('com.example.default', { $type: 'com.example.default', object: {}, }) expect(result).toEqual({ $type: 'com.example.default', boolean: false, integer: 0, string: '', object: { boolean: true, integer: 1, string: 'x', }, }) expect(result).not.toHaveProperty('datetime') }) it('Handles unions correctly', () => { lex.assertValidRecord('com.example.union', { $type: 'com.example.union', unionOpen: { $type: 'com.example.kitchenSink#object', object: { boolean: true }, array: ['one', 'two'], boolean: true, integer: 123, string: 'string', }, unionClosed: { $type: 'com.example.kitchenSink#subobject', boolean: true, }, }) lex.assertValidRecord('com.example.union', { $type: 'com.example.union', unionOpen: { $type: 'com.example.other', }, unionClosed: { $type: 'com.example.kitchenSink#subobject', boolean: true, }, }) expect(() => lex.assertValidRecord('com.example.union', { $type: 'com.example.union', unionOpen: {}, unionClosed: {}, }), ).toThrow( 'Record/unionOpen must be an object which includes the "$type" property', ) expect(() => lex.assertValidRecord('com.example.union', { $type: 'com.example.union', unionOpen: { $type: 'com.example.other', }, unionClosed: { $type: 'com.example.other', boolean: true, }, }), ).toThrow( 'Record/unionClosed $type must be one of lex:com.example.kitchenSink#object, lex:com.example.kitchenSink#subobject', ) }) it('Handles unknowns correctly', () => { lex.assertValidRecord('com.example.unknown', { $type: 'com.example.unknown', unknown: { foo: 'bar' }, }) expect(() => lex.assertValidRecord('com.example.unknown', { $type: 'com.example.unknown', }), ).toThrow('Record must have the property "unknown"') }) it('Applies array length constraints', () => { lex.assertValidRecord('com.example.arrayLength', { $type: 'com.example.arrayLength', array: [1, 2, 3], }) expect(() => lex.assertValidRecord('com.example.arrayLength', { $type: 'com.example.arrayLength', array: [1], }), ).toThrow('Record/array must not have fewer than 2 elements') expect(() => lex.assertValidRecord('com.example.arrayLength', { $type: 'com.example.arrayLength', array: [1, 2, 3, 4, 5], }), ).toThrow('Record/array must not have more than 4 elements') }) it('Applies array item constraints', () => { expect(() => lex.assertValidRecord('com.example.arrayLength', { $type: 'com.example.arrayLength', array: [1, '2', 3], }), ).toThrow('Record/array/1 must be an integer') expect(() => lex.assertValidRecord('com.example.arrayLength', { $type: 'com.example.arrayLength', array: [1, undefined, 3], }), ).toThrow('Record/array/1 must be an integer') }) it('Applies boolean const constraint', () => { lex.assertValidRecord('com.example.boolConst', { $type: 'com.example.boolConst', boolean: false, }) expect(() => lex.assertValidRecord('com.example.boolConst', { $type: 'com.example.boolConst', boolean: true, }), ).toThrow('Record/boolean must be false') }) it('Applies integer range constraint', () => { lex.assertValidRecord('com.example.integerRange', { $type: 'com.example.integerRange', integer: 2, }) expect(() => lex.assertValidRecord('com.example.integerRange', { $type: 'com.example.integerRange', integer: 1, }), ).toThrow('Record/integer can not be less than 2') expect(() => lex.assertValidRecord('com.example.integerRange', { $type: 'com.example.integerRange', integer: 5, }), ).toThrow('Record/integer can not be greater than 4') }) it('Applies integer enum constraint', () => { lex.assertValidRecord('com.example.integerEnum', { $type: 'com.example.integerEnum', integer: 2, }) expect(() => lex.assertValidRecord('com.example.integerEnum', { $type: 'com.example.integerEnum', integer: 0, }), ).toThrow('Record/integer must be one of (1|2)') }) it('Applies integer const constraint', () => { lex.assertValidRecord('com.example.integerConst', { $type: 'com.example.integerConst', integer: 0, }) expect(() => lex.assertValidRecord('com.example.integerConst', { $type: 'com.example.integerConst', integer: 1, }), ).toThrow('Record/integer must be 0') }) it('Applies integer whole-number constraint', () => { expect(() => lex.assertValidRecord('com.example.integerRange', { $type: 'com.example.integerRange', integer: 2.5, }), ).toThrow('Record/integer must be an integer') }) it('Applies string length constraint', () => { // Shorter than two UTF8 characters expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '', }), ).toThrow('Record/string must not be shorter than 2 characters') expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'a', }), ).toThrow('Record/string must not be shorter than 2 characters') // Two to four UTF8 characters lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'ab', }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '\u0301', // Combining acute accent (2 bytes) }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'a\u0301', // 'a' + combining acute accent (1 + 2 bytes = 3 bytes) }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'aé', // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'abc', }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '一', // CJK character (3 bytes) }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '\uD83D', // Unpaired high surrogate (3 bytes) }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'abcd', }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'éé', // 'é' + 'é' (2 + 2 bytes = 4 bytes) }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'aaé', // 1 + 1 + 2 = 4 bytes }) lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '👋', // 4 bytes }) expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'abcde', }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'a\u0301\u0301', // 1 + (2 * 2) = 5 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '\uD83D\uD83D', // Two unpaired high surrogates (3 * 2 = 6 bytes) }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: 'ééé', // 2 + 2 + 2 bytes = 6 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '👋a', // 4 + 1 bytes = 5 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '👨👨', // 4 + 4 = 8 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLength', { $type: 'com.example.stringLength', string: '👨‍👩‍👧‍👧', // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes }), ).toThrow('Record/string must not be longer than 4 characters') }) it('Applies string length constraint (no minLength)', () => { // Shorter than two UTF8 characters lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '', }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'a', }) // Two to four UTF8 characters lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'ab', }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '\u0301', // Combining acute accent (2 bytes) }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'a\u0301', // 'a' + combining acute accent (1 + 2 bytes = 3 bytes) }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'aé', // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'abc', }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '一', // CJK character (3 bytes) }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '\uD83D', // Unpaired high surrogate (3 bytes) }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'abcd', }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'éé', // 'é' + 'é' (2 + 2 bytes = 4 bytes) }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'aaé', // 1 + 1 + 2 = 4 bytes }) lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '👋', // 4 bytes }) expect(() => lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'abcde', }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'a\u0301\u0301', // 1 + (2 * 2) = 5 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '\uD83D\uD83D', // Two unpaired high surrogates (3 * 2 = 6 bytes) }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: 'ééé', // 2 + 2 + 2 bytes = 6 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '👋a', // 4 + 1 bytes = 5 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '👨👨', // 4 + 4 = 8 bytes }), ).toThrow('Record/string must not be longer than 4 characters') expect(() => lex.assertValidRecord('com.example.stringLengthNoMinLength', { $type: 'com.example.stringLengthNoMinLength', string: '👨‍👩‍👧‍👧', // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes }), ).toThrow('Record/string must not be longer than 4 characters') }) it('Applies grapheme string length constraint', () => { // Shorter than two graphemes expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: '', }), ).toThrow('Record/string must not be shorter than 2 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: '\u0301\u0301\u0301', // Three combining acute accents }), ).toThrow('Record/string must not be shorter than 2 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'a', }), ).toThrow('Record/string must not be shorter than 2 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'a\u0301\u0301\u0301\u0301', // 'á́́́' ('a' with four combining acute accents) }), ).toThrow('Record/string must not be shorter than 2 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: '5\uFE0F', // '5️' with emoji presentation }), ).toThrow('Record/string must not be shorter than 2 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: '👨‍👩‍👧‍👧', }), ).toThrow('Record/string must not be shorter than 2 graphemes') // Two to four graphemes lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'ab', }) lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'a\u0301b', // 'áb' with combining accent }) lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'a\u0301b\u0301', // 'áb́' }) lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: '😀😀', }) lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: '12👨‍👩‍👧‍👧', }) lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'abcd', }) lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'a\u0301b\u0301c\u0301d\u0301', // 'áb́ćd́' }) // Longer than four graphemes expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'abcde', }), ).toThrow('Record/string must not be longer than 4 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'a\u0301b\u0301c\u0301d\u0301e\u0301', // 'áb́ćd́é' }), ).toThrow('Record/string must not be longer than 4 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: '😀😀😀😀😀', }), ).toThrow('Record/string must not be longer than 4 graphemes') expect(() => lex.assertValidRecord('com.example.stringLengthGrapheme', { $type: 'com.example.stringLengthGrapheme', string: 'ab😀de', }), ).toThrow('Record/string must not be longer than 4 graphemes') }) it('Applies string enum constraint', () => { lex.assertValidRecord('com.example.stringEnum', { $type: 'com.example.stringEnum', string: 'a', }) expect(() => lex.assertValidRecord('com.example.stringEnum', { $type: 'com.example.stringEnum', string: 'c', }), ).toThrow('Record/string must be one of (a|b)') }) it('Applies string const constraint', () => { lex.assertValidRecord('com.example.stringConst', { $type: 'com.example.stringConst', string: 'a', }) expect(() => lex.assertValidRecord('com.example.stringConst', { $type: 'com.example.stringConst', string: 'b', }), ).toThrow('Record/string must be a') }) it('Applies datetime formatting constraint', () => { for (const datetime of [ '2022-12-12T00:50:36.809Z', '2022-12-12T00:50:36Z', '2022-12-12T00:50:36.8Z', '2022-12-12T00:50:36.80Z', '2022-12-12T00:50:36+00:00', '2022-12-12T00:50:36.8+00:00', '2022-12-11T19:50:36-05:00', '2022-12-11T19:50:36.8-05:00', '2022-12-11T19:50:36.80-05:00', '2022-12-11T19:50:36.809-05:00', ]) { lex.assertValidRecord('com.example.datetime', { $type: 'com.example.datetime', datetime, }) } expect(() => lex.assertValidRecord('com.example.datetime', { $type: 'com.example.datetime', datetime: 'bad date', }), ).toThrow( 'Record/datetime must be an valid atproto datetime (both RFC-3339 and ISO-8601)', ) }) it('Applies uri formatting constraint', () => { for (const uri of [ 'https://example.com', 'https://example.com/with/path', 'https://example.com/with/path?and=query', 'at://bsky.social', 'did:example:test', ]) { lex.assertValidRecord('com.example.uri', { $type: 'com.example.uri', uri, }) } expect(() => lex.assertValidRecord('com.example.uri', { $type: 'com.example.uri', uri: 'not a uri', }), ).toThrow('Record/uri must be a uri') }) it('Applies at-uri formatting constraint', () => { lex.assertValidRecord('com.example.atUri', { $type: 'com.example.atUri', atUri: 'at://did:web:example.com/com.example.test/self', }) expect(() => lex.assertValidRecord('com.example.atUri', { $type: 'com.example.atUri', atUri: 'http://not-atproto.com', }), ).toThrow('Record/atUri must be a valid at-uri') }) it('Applies did formatting constraint', () => { lex.assertValidRecord('com.example.did', { $type: 'com.example.did', did: 'did:web:example.com', }) lex.assertValidRecord('com.example.did', { $type: 'com.example.did', did: 'did:plc:12345678abcdefghijklmnop', }) expect(() => lex.assertValidRecord('com.example.did', { $type: 'com.example.did', did: 'bad did', }), ).toThrow('Record/did must be a valid did') expect(() => lex.assertValidRecord('com.example.did', { $type: 'com.example.did', did: 'did:short', }), ).toThrow('Record/did must be a valid did') }) it('Applies handle formatting constraint', () => { lex.assertValidRecord('com.example.handle', { $type: 'com.example.handle', handle: 'test.bsky.social', }) lex.assertValidRecord('com.example.handle', { $type: 'com.example.handle', handle: 'bsky.test', }) expect(() => lex.assertValidRecord('com.example.handle', { $type: 'com.example.handle', handle: 'bad handle', }), ).toThrow('Record/handle must be a valid handle') expect(() => lex.assertValidRecord('com.example.handle', { $type: 'com.example.handle', handle: '-bad-.test', }), ).toThrow('Record/handle must be a valid handle') }) it('Applies at-identifier formatting constraint', () => { lex.assertValidRecord('com.example.atIdentifier', { $type: 'com.example.atIdentifier', atIdentifier: 'bsky.test', }) lex.assertValidRecord('com.example.atIdentifier', { $type: 'com.example.atIdentifier', atIdentifier: 'did:plc:12345678abcdefghijklmnop', }) expect(() => lex.assertValidRecord('com.example.atIdentifier', { $type: 'com.example.atIdentifier', atIdentifier: 'bad id', }), ).toThrow('Record/atIdentifier must be a valid did or a handle') expect(() => lex.assertValidRecord('com.example.atIdentifier', { $type: 'com.example.atIdentifier', atIdentifier: '-bad-.test', }), ).toThrow('Record/atIdentifier must be a valid did or a handle') }) it('Applies nsid formatting constraint', () => { lex.assertValidRecord('com.example.nsid', { $type: 'com.example.nsid', nsid: 'com.atproto.test', }) lex.assertValidRecord('com.example.nsid', { $type: 'com.example.nsid', nsid: 'app.bsky.nested.test', }) expect(() => lex.assertValidRecord('com.example.nsid', { $type: 'com.example.nsid', nsid: 'bad nsid', }), ).toThrow('Record/nsid must be a valid nsid') expect(() => lex.assertValidRecord('com.example.nsid', { $type: 'com.example.nsid', nsid: 'com.bad-.foo', }), ).toThrow('Record/nsid must be a valid nsid') }) it('Applies cid formatting constraint', () => { lex.assertValidRecord('com.example.cid', { $type: 'com.example.cid', cid: 'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a', }) expect(() => lex.assertValidRecord('com.example.cid', { $type: 'com.example.cid', cid: 'abapsdofiuwrpoiasdfuaspdfoiu', }), ).toThrow('Record/cid must be a cid string') }) it('Applies language formatting constraint', () => { lex.assertValidRecord('com.example.language', { $type: 'com.example.language', language: 'en-US-boont', }) expect(() => lex.assertValidRecord('com.example.language', { $type: 'com.example.language', language: 'not-a-language-', }), ).toThrow('Record/language must be a well-formed BCP 47 language tag') }) it('Applies bytes length constraints', () => { lex.assertValidRecord('com.example.byteLength', { $type: 'com.example.byteLength', bytes: new Uint8Array([1, 2, 3]), }) expect(() => lex.assertValidRecord('com.example.byteLength', { $type: 'com.example.byteLength', bytes: new Uint8Array([1]), }), ).toThrow('Record/bytes must not be smaller than 2 bytes') expect(() => lex.assertValidRecord('com.example.byteLength', { $type: 'com.example.byteLength', bytes: new Uint8Array([1, 2, 3, 4, 5]), }), ).toThrow('Record/bytes must not be larger than 4 bytes') }) }) describe('XRPC parameter validation', () => { const lex = new Lexicons(LexiconDocs) it('Passes valid parameters', () => { const queryResult = lex.assertValidXrpcParams('com.example.query', { boolean: true, integer: 123, string: 'string', array: ['x', 'y'], }) expect(queryResult).toEqual({ boolean: true, integer: 123, string: 'string', array: ['x', 'y'], def: 0, }) const paramResult = lex.assertValidXrpcParams('com.example.procedure', { boolean: true, integer: 123, string: 'string', array: ['x', 'y'], def: 1, }) expect(paramResult).toEqual({ boolean: true, integer: 123, string: 'string', array: ['x', 'y'], def: 1, }) }) it('Handles required correctly', () => { lex.assertValidXrpcParams('com.example.query', { boolean: true, integer: 123, }) expect(() => lex.assertValidXrpcParams('com.example.query', { boolean: true, }), ).toThrow('Params must have the property "integer"') expect(() => lex.assertValidXrpcParams('com.example.query', { boolean: true, integer: undefined, }), ).toThrow('Params must have the property "integer"') }) it('Validates parameter types', () => { expect(() => lex.assertValidXrpcParams('com.example.query', { boolean: 'string', integer: 123, string: 'string', }), ).toThrow('boolean must be a boolean') expect(() => lex.assertValidXrpcParams('com.example.query', { boolean: true, float: 123.45, integer: 123, string: 'string', array: 'x', }), ).toThrow('array must be an array') }) }) describe('XRPC input validation', () => { const lex = new Lexicons(LexiconDocs) it('Passes valid inputs', () => { lex.assertValidXrpcInput('com.example.procedure', { object: { boolean: true }, array: ['one', 'two'], boolean: true, float: 123.45, integer: 123, string: 'string', }) }) it('Validates the input', () => { // dont need to check this extensively since it's the same logic as tested in record validation expect(() => lex.assertValidXrpcInput('com.example.procedure', { object: { boolean: 'string' }, array: ['one', 'two'], boolean: true, float: 123.45, integer: 123, string: 'string', }), ).toThrow('Input/object/boolean must be a boolean') expect(() => lex.assertValidXrpcInput('com.example.procedure', {})).toThrow( 'Input must have the property "object"', ) }) }) describe('XRPC output validation', () => { const lex = new Lexicons(LexiconDocs) it('Passes valid outputs', () => { lex.assertValidXrpcOutput('com.example.query', { object: { boolean: true }, array: ['one', 'two'], boolean: true, float: 123.45, integer: 123, string: 'string', }) lex.assertValidXrpcOutput('com.example.procedure', { object: { boolean: true }, array: ['one', 'two'], boolean: true, float: 123.45, integer: 123, string: 'string', }) }) it('Validates the output', () => { // dont need to check this extensively since it's the same logic as tested in record validation expect(() => lex.assertValidXrpcOutput('com.example.query', { object: { boolean: 'string' }, array: ['one', 'two'], boolean: true, float: 123.45, integer: 123, string: 'string', }), ).toThrow('Output/object/boolean must be a boolean') expect(() => lex.assertValidXrpcOutput('com.example.procedure', {}), ).toThrow('Output must have the property "object"') }) })