import { BaseRecord, ID } from '../BaseRecord' import { createRecordType } from '../RecordType' import { Store, StoreSnapshot, StoreValidator } from '../Store' interface Book extends BaseRecord<'book'> { title: string author: ID numPages: number } const Book = createRecordType('book') const bookValidator: StoreValidator = { validate(value) { const book = value as Book if (!book.id.startsWith('book:')) throw Error() if (book.typeName !== 'book') throw Error() if (typeof book.title !== 'string') throw Error() if (!Number.isFinite(book.numPages)) throw Error() if (book.numPages < 0) throw Error() return book }, } interface Author extends BaseRecord<'author'> { name: string isPseudonym: boolean } const authorValidator: StoreValidator = { validate(value) { const author = value as Author if (author.typeName !== 'author') throw Error() if (!author.id.startsWith('author:')) throw Error() if (typeof author.name !== 'string') throw Error() if (typeof author.isPseudonym !== 'boolean') throw Error() return author }, } const Author = createRecordType('author').withDefaultProperties(() => ({ isPseudonym: false, })) describe('Store with validation', () => { let store: Store let onErrorFn: any beforeEach(() => { onErrorFn = jest.fn() store = new Store({ validators: { author: authorValidator, book: bookValidator, }, onError: onErrorFn, }) }) it('Accepts valid records and rejects invalid records', () => { store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })]) expect(onErrorFn).toHaveBeenCalledTimes(0) expect(store.query.records('author').value).toEqual([ { id: 'author:tolkein', typeName: 'author', name: 'J.R.R Tolkein', isPseudonym: false }, ]) expect(() => { store.put([ { id: Book.createCustomId('the-hobbit'), typeName: 'book', title: 'The Hobbit', numPages: -1, // <---- Invalid! author: Author.createCustomId('tolkein'), }, ]) }).toThrow() expect(onErrorFn).toHaveBeenCalledTimes(1) expect(store.query.records('book').value).toEqual([]) }) }) describe('Validating initial data', () => { let snapshot: StoreSnapshot beforeEach(() => { const authorId = Author.createCustomId('tolkein') const authorRecord = Author.create({ name: 'J.R.R Tolkein', id: authorId }) const bookId = Book.createCustomId('the-hobbit') const bookRecord = Book.create({ title: 'The Hobbit', numPages: 300, author: authorId, id: bookId, }) snapshot = { [authorId]: authorRecord, [bookId]: bookRecord, } }) it('Validates initial data', () => { expect(() => { new Store({ validators: { author: authorValidator, book: bookValidator, }, initialData: snapshot, }) }).not.toThrowError() expect(() => { // @ts-expect-error snapshot[0].name = 4 new Store({ validators: { author: authorValidator, book: bookValidator, }, initialData: snapshot, }) }).toThrowError() }) it('Skips initial validation when flag is set', () => { const err = jest.fn() // @ts-expect-error snapshot['author:tolkein'].name = 4 // It shouldn't throw when we load bad initial data without the flag set // whilst we're doing medium validation new Store({ validators: { author: authorValidator, book: bookValidator, }, initialData: snapshot, UNSAFE_SKIP_INITIAL_VALIDATION: false, onError: err, }) // but it should report the error expect(err).toHaveBeenCalledTimes(1) // if the flag is set, we don't even call onError: const store3 = new Store({ validators: { author: authorValidator, book: bookValidator, }, initialData: snapshot, UNSAFE_SKIP_INITIAL_VALIDATION: true, onError: err, }) expect(err).toHaveBeenCalledTimes(1) expect(() => store3.validate('tests')).toThrow() expect(err).toHaveBeenCalledTimes(2) }) }) describe('Loose validators', () => { it('Works with loosely defined validators', () => { const store = new Store({ validators: { author: { validate: (author) => author as Author }, book: { validate: (book) => book as Book }, }, }) }) }) describe.only('Create & update validations', () => { const authorId = Author.createCustomId('tolkein') const bookId = Book.createCustomId('the-hobbit') const initialAuthor = Author.create({ name: 'J.R.R Tolkein', id: authorId }) const initialBook = Book.create({ // @ts-expect-error - deliberately invalid data title: 4, numPages: 300, author: authorId, id: bookId, }) let store: Store beforeEach(() => { store = new Store({ validators: { author: authorValidator, book: bookValidator, }, initialData: { [authorId]: initialAuthor, [bookId]: initialBook, }, UNSAFE_SKIP_INITIAL_VALIDATION: true, onError: jest.fn(), }) }) it('Allows invalid updates to already invalid records', () => { store.put([ { ...initialBook, numPages: -1, }, ]) expect(store.onError).toHaveBeenCalledTimes(1) expect(store.get(bookId)).toEqual({ ...initialBook, numPages: -1, }) }) it('Prevents updates to valid records that would make them invalid', () => { expect(() => store.put([ { ...initialAuthor, // @ts-expect-error - deliberately invalid data name: null, }, ]) ).toThrow() expect(store.onError).toHaveBeenCalledTimes(1) expect(store.get(authorId)).toEqual(initialAuthor) }) it('Prevents newly created invalid records', () => { const newAuthorId = Author.createCustomId('shearing') expect(() => store.put([ Author.create({ // @ts-expect-error - deliberately invalid data name: null, }), ]) ).toThrow() expect(store.onError).toHaveBeenCalledTimes(1) expect(store.get(newAuthorId)).toBeUndefined() }) })