UNPKG

apollo-link-state

Version:

An easy way to manage local state with Apollo Link

940 lines (845 loc) 23.6 kB
import gql from 'graphql-tag'; import { ApolloLink, execute, Observable } from 'apollo-link'; import { ApolloClient } from 'apollo-client'; import { InMemoryCache, IntrospectionFragmentMatcher, } from 'apollo-cache-inmemory'; import { print } from 'graphql/language/printer'; import { parse } from 'graphql/language/parser'; import { introspectionQuery } from 'graphql/utilities'; import { withClientState } from '../'; const makeTerminatingCheck = (done, body) => { return (...args) => { try { body(...args); done(); } catch (error) { done.fail(error); } }; }; describe('non cache usage', () => { it("doesn't stop normal operations from working", () => { const query = gql` { field } `; const link = new ApolloLink(() => Observable.of({ data: { field: 1 } })); const local = withClientState(); const client = new ApolloClient({ cache: new InMemoryCache(), link: local.concat(link), }); return client.query({ query }).then(({ data }) => { expect({ ...data }).toMatchObject({ field: 1 }); }); }); it('works for an introspection query', () => { const query = gql`${introspectionQuery}`; const link = new ApolloLink(() => Observable.of({ errors: [{ message: 'no introspection result found' }] }), ); const local = withClientState(); const client = new ApolloClient({ cache: new InMemoryCache(), link: local.concat(link), }); return client .query({ query }) .then(() => { throw new Error('should not call'); }) .catch(error => expect(error.message).toMatch(/no introspection/)); }); it('lets you set default values from resolvers', () => { const query = gql` { field @client } `; const local = withClientState({ resolvers: { Query: { field: () => 1, }, }, }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local, }); return client.query({ query }).then(({ data }) => { expect({ ...data }).toMatchObject({ field: 1 }); }); }); it('caches the data for future lookups', () => { const query = gql` { field @client } `; let count = 0; const local = withClientState({ resolvers: { Query: { field: () => { count++; return 1; }, }, }, }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local, }); return client .query({ query }) .then(({ data }) => { expect({ ...data }).toMatchObject({ field: 1 }); expect(count).toBe(1); }) .then(() => client.query({ query }).then(({ data }) => { expect({ ...data }).toMatchObject({ field: 1 }); expect(count).toBe(1); }), ); }); it('honors fetchPolicy', () => { const query = gql` { field @client } `; let count = 0; const local = withClientState({ resolvers: { Query: { field: () => { count++; return 1; }, }, }, }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local, }); return client .query({ query }) .then(({ data }) => { expect({ ...data }).toMatchObject({ field: 1 }); expect(count).toBe(1); }) .then(() => client .query({ query, fetchPolicy: 'network-only' }) .then(({ data }) => { expect({ ...data }).toMatchObject({ field: 1 }); expect(count).toBe(2); }), ); }); it('supports subscriptions', done => { const query = gql` subscription { field } `; const link = new ApolloLink(() => Observable.of({ data: { field: 1 } }, { data: { field: 2 } }), ); const local = withClientState(); const client = new ApolloClient({ cache: new InMemoryCache(), link: local.concat(link), }); let counter = 0; expect.assertions(2); return client.subscribe({ query }).forEach(item => { expect(item).toMatchObject({ data: { field: ++counter } }); if (counter === 2) { done(); } }); }); it('uses fragment matcher', () => { const query = gql` { foo { ... on Bar { bar @client } ... on Baz { baz @client } } } `; const link = new ApolloLink(() => Observable.of({ data: { foo: [{ __typename: 'Bar' }, { __typename: 'Baz' }] }, }), ); const local = withClientState({ resolvers: { Bar: { bar: () => 'Bar', }, Baz: { baz: () => 'Baz', }, }, fragmentMatcher: ({ __typename }, typeCondition) => __typename === typeCondition, }); const client = new ApolloClient({ cache: new InMemoryCache({ fragmentMatcher: new IntrospectionFragmentMatcher({ introspectionQueryResultData: { __schema: { types: [ { kind: 'UnionTypeDefinition', name: 'Foo', possibleTypes: [{ name: 'Bar' }, { name: 'Baz' }], }, ], }, }, }), }), link: local.concat(link), }); return client.query({ query }).then(({ data }) => { expect(data).toMatchObject({ foo: [{ bar: 'Bar' }, { baz: 'Baz' }] }); }); }); }); describe('cache usage', () => { it('still lets you query the cache without passing in a resolver map', () => { const query = gql` { field @client } `; const cache = new InMemoryCache(); const client = new ApolloClient({ cache, link: withClientState(), }); cache.writeQuery({ query, data: { field: 'yo' } }); client .query({ query }) .then(({ data }) => expect({ ...data }).toMatchObject({ field: 'yo' })); }); it('lets you write to the cache with a mutation', () => { const query = gql` { field @client } `; const mutation = gql` mutation start { start @client } `; const local = withClientState({ resolvers: { Mutation: { start: (_, $, { cache }: { cache: InMemoryCache }) => { cache.writeQuery({ query, data: { field: 1 } }); return { start: true }; }, }, }, }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local, }); return client .mutate({ mutation }) .then(() => client.query({ query })) .then(({ data }) => { expect({ ...data }).toMatchObject({ field: 1 }); }); }); it('lets you write to the cache with a mutation and it rerenders automatically', done => { const query = gql` { field @client } `; const mutation = gql` mutation start { start @client } `; const local = withClientState({ resolvers: { Query: { field: () => 0, }, Mutation: { start: (_, $, { cache }: { cache: InMemoryCache }) => { cache.writeQuery({ query, data: { field: 1 } }); return { start: true }; }, }, }, }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local, }); let count = 0; client.watchQuery({ query }).subscribe({ next: ({ data }) => { count++; if (count === 1) { expect({ ...data }).toMatchObject({ field: 0 }); client.mutate({ mutation }); } if (count === 2) { expect({ ...data }).toMatchObject({ field: 1 }); done(); } }, }); }); it('lets you write to the cache with a mutation using variables', () => { const query = gql` { field @client } `; const mutation = gql` mutation start($id: ID!) { start(field: $id) @client { field } } `; const local = withClientState({ resolvers: { Mutation: { start: (_, variables, { cache }) => { cache.writeQuery({ query, data: { field: variables.field } }); return { __typename: 'Field', field: variables.field, }; }, }, }, }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local, }); return client .mutate({ mutation, variables: { id: '1234' } }) .then(({ data }) => { expect({ ...data }).toEqual({ start: { field: '1234', __typename: 'Field' }, }); }) .then(() => client.query({ query })) .then(({ data }) => { expect({ ...data }).toMatchObject({ field: '1234' }); }); }); it('writeDefaults lets you write defaults to the cache after the store is reset', done => { const mutation = gql` mutation foo { foo @client } `; const query = gql` { foo @client } `; const cache = new InMemoryCache(); const stateLink = withClientState({ defaults: { foo: 'bar', }, resolvers: { Mutation: { foo: (_, $, { cache }) => { cache.writeData({ data: { foo: 'woo' } }); return null; }, }, }, cache, }); const client = new ApolloClient({ cache, link: stateLink, }); client.onResetStore(stateLink.writeDefaults); client .query({ query }) .then(({ data }) => { expect({ ...data }).toMatchObject({ foo: 'bar' }); }) .catch(done.fail); client .mutate({ mutation }) .then(() => client.query({ query })) .then(({ data }) => { expect({ ...data }).toMatchObject({ foo: 'woo' }); }) //should be default after this reset call .then(() => client.resetStore() as Promise<null>) .then(() => client.query({ query })) .then(({ data }) => { expect({ ...data }).toMatchObject({ foo: 'bar' }); done(); }) .catch(done.fail); }); describe('after resetStore', () => { const counterQuery = gql` query { counter @client } `; const plusMutation = gql` mutation plus { plus @client } `; //ensures no warnings let oldWarn; let cache: InMemoryCache; beforeEach(() => { oldWarn = console.warn; console.warn = message => { fail(`warn should not be called, message: ${message}`); }; cache = new InMemoryCache(); }); afterEach(() => { console.warn = oldWarn; }); const createClient = stateLink => new ApolloClient({ cache, link: ApolloLink.from([ stateLink, new ApolloLink(() => { throw Error('should never call forward'); }), ]), }); it('returns the default data after resetStore with no Query specified', done => { const stateLink = withClientState({ cache, resolvers: { Mutation: { plus: (_, __, { cache }) => { const { counter } = cache.readQuery({ query: counterQuery }); const data = { counter: counter + 1, }; cache.writeData({ data }); return null; }, }, }, defaults: { counter: 10, }, }); const checkedCount = [10, 11, 12, 10]; const client = createClient(stateLink); const componentObservable = client.watchQuery({ query: counterQuery }); const unsub = componentObservable.subscribe({ next: ({ data }) => { try { expect(data).toMatchObject({ counter: checkedCount.shift() }); } catch (e) { done.fail(e); } }, error: done.fail, complete: done.fail, }); client .mutate({ mutation: plusMutation }) .then(() => { expect(cache.readQuery({ query: counterQuery })).toMatchObject({ counter: 11, }); expect(client.query({ query: counterQuery })).resolves.toMatchObject({ data: { counter: 11 }, }); }) .then(() => client.mutate({ mutation: plusMutation })) .then(() => { expect(cache.readQuery({ query: counterQuery })).toMatchObject({ counter: 12, }); expect(client.query({ query: counterQuery })).resolves.toMatchObject({ data: { counter: 12 }, }); }) .then(() => client.resetStore() as Promise<null>) .then(() => { expect(client.query({ query: counterQuery })) .resolves.toMatchObject({ data: { counter: 10 } }) .then(() => { expect(checkedCount.length).toBe(0); done(); }); }) .catch(done.fail); }); it('returns the Query result after resetStore', async done => { const stateLink = withClientState({ cache, resolvers: { Query: { counter: () => 0, }, Mutation: { plus: (_, __, { cache }) => { const { counter } = cache.readQuery({ query: counterQuery }); const data = { counter: counter + 1, }; cache.writeData({ data }); return null; }, }, }, defaults: { counter: 10, }, }); const client = createClient(stateLink); await client.mutate({ mutation: plusMutation }); expect(cache.readQuery({ query: counterQuery })).toMatchObject({ counter: 11, }); await client.mutate({ mutation: plusMutation }); expect(cache.readQuery({ query: counterQuery })).toMatchObject({ counter: 12, }); await expect( client.query({ query: counterQuery }), ).resolves.toMatchObject({ data: { counter: 12 }, }); (client.resetStore() as Promise<null>) .then(() => { expect(client.query({ query: counterQuery })) .resolves.toMatchObject({ data: { counter: 0 } }) .then(done) .catch(done.fail); }) .catch(done.fail); }); //should work, but currently does not due to resetStore calling broadcastQueries, then the onResetStore callbacks it.skip('returns the default data from cache in a Query resolver with writeDefaults callback enabled', done => { const stateLink = withClientState({ cache, resolvers: { Query: { counter: () => { //This cache read does not see any data return (cache.readQuery({ query: counterQuery }) as any).counter; }, }, Mutation: { plus: (_, __, { cache }) => { const { counter } = cache.readQuery({ query: counterQuery }); const data = { counter: counter + 1, }; cache.writeData({ data }); return null; }, }, }, defaults: { counter: 10, }, }); const client = createClient(stateLink); client.onResetStore(stateLink.writeDefaults); client.mutate({ mutation: plusMutation }); client.mutate({ mutation: plusMutation }); expect(cache.readQuery({ query: counterQuery })).toMatchObject({ counter: 12, }); expect(client.query({ query: counterQuery })).resolves.toMatchObject({ data: { counter: 12 }, }); let called = false; const componentObservable = client.watchQuery({ query: counterQuery }); const unsub = componentObservable.subscribe({ next: ({ data }) => { try { //this fails expect(data).toMatchObject({ counter: 10 }); called = true; } catch (e) { done.fail(e); } }, error: done.fail, complete: done.fail, }); (client.resetStore() as Promise<null>) .then(() => { expect(client.query({ query: counterQuery })) .resolves.toMatchObject({ data: { counter: 10 } }) .then( makeTerminatingCheck( () => { unsub.unsubscribe(); done(); }, () => { expect(called); }, ), ) .catch(done.fail); }) .catch(done.fail); }); it('find no data from cache in a Query resolver with no writeDefaults callback enabled', done => { const stateLink = withClientState({ cache, resolvers: { Query: { counter: () => { try { return (cache.readQuery({ query: counterQuery }) as any) .counter; } catch (error) { try { expect(error.message).toMatch(/field counter/); } catch (e) { done.fail(e); } unsub.unsubscribe(); done(); } return -1; // to remove warning from in-memory-cache }, }, }, defaults: { counter: 10, }, }); const client = createClient(stateLink); const componentObservable = client.watchQuery({ query: counterQuery }); const unsub = componentObservable.subscribe({ next: ({ data }) => done.fail, error: done.fail, complete: done.fail, }); client.resetStore() as Promise<null>; }); it('should warn when no default or Query resolver specified', done => { console.warn = message => { unsub.unsubscribe(); done(); }; const stateLink = withClientState({ cache, resolvers: { Query: { counter: () => {}, }, //return empty object, does not have counter field Mutation: { plus: (_, __, { cache }) => { const { counter } = cache.readQuery({ query: counterQuery }); const data = { counter: counter + 1, }; cache.writeData({ data }); return null; }, }, }, defaults: { counter: 10, }, }); const client = createClient(stateLink); client.mutate({ mutation: plusMutation }); const componentObservable = client.watchQuery({ query: counterQuery }); let calledOnce = true; const unsub = componentObservable.subscribe({ next: data => { try { expect(calledOnce); calledOnce = false; } catch (e) { done.fail(e); } }, error: done.fail, complete: done.fail, }); client.resetStore(); }); }); }); describe('sample usage', () => { it('works for a simple counter app', done => { const query = gql` query GetCount { count @client lastCount # stored in db on server } `; const increment = gql` mutation Increment($amount: Int = 1) { increment(amount: $amount) @client } `; const decrement = gql` mutation Decrement($amount: Int = 1) { decrement(amount: $amount) @client } `; const update = (query, updater) => (result, variables, { cache }) => { const data = updater(client.readQuery({ query, variables }), variables); cache.writeQuery({ query, variables, data }); return null; }; const local = withClientState({ resolvers: { Query: { // initial count count: () => 0, }, Mutation: { increment: update(query, ({ count, ...rest }, { amount }) => ({ ...rest, count: count + amount, })), decrement: update(query, ({ count, ...rest }, { amount }) => ({ ...rest, count: count - amount, })), }, }, }); const http = new ApolloLink(operation => { expect(operation.operationName).toBe('GetCount'); return Observable.of({ data: { lastCount: 1 } }); }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local.concat(http), }); let count = 0; client.watchQuery({ query }).subscribe({ next: ({ data }) => { count++; if (count === 1) { try { expect({ ...data }).toMatchObject({ count: 0, lastCount: 1 }); } catch (e) { done.fail(e); } client.mutate({ mutation: increment, variables: { amount: 2 } }); } if (count === 2) { try { expect({ ...data }).toMatchObject({ count: 2, lastCount: 1 }); } catch (e) { done.fail(e); } client.mutate({ mutation: decrement, variables: { amount: 1 } }); } if (count === 3) { try { expect({ ...data }).toMatchObject({ count: 1, lastCount: 1 }); } catch (e) { done.fail(e); } done(); } }, error: e => done.fail(e), complete: done.fail, }); }); it('works for a simple todo app', done => { const query = gql` query GetTasks { todos @client { message title } } `; const mutation = gql` mutation AddTodo($message: String, $title: String) { addTodo(message: $message, title: $title) @client } `; const update = (query, updater) => (result, variables, { cache }) => { const data = updater(client.readQuery({ query, variables }), variables); cache.writeQuery({ query, variables, data }); return null; }; const local = withClientState({ resolvers: { Query: { todos: () => [], }, Mutation: { addTodo: update(query, ({ todos }, { message, title }) => ({ todos: todos.concat([{ message, title, __typename: 'Todo' }]), })), }, }, }); const client = new ApolloClient({ cache: new InMemoryCache(), link: local, }); let count = 0; client.watchQuery({ query }).subscribe({ next: ({ data }) => { count++; if (count === 1) { expect({ ...data }).toMatchObject({ todos: [] }); client.mutate({ mutation, variables: { title: 'Apollo Client 2.0', message: 'ship it', }, }); } else if (count === 2) { expect(data.todos.map(x => ({ ...x }))).toMatchObject([ { title: 'Apollo Client 2.0', message: 'ship it', __typename: 'Todo', }, ]); done(); } }, }); }); });