[CORL-331] Better tests with types (#2270)

* feat: suspending, banning, now propogation

* feat: new mutation api with hooks support

* feat: better types in tests and refactor

* fix: lint
This commit is contained in:
Kiwi
2019-04-23 19:46:14 +00:00
committed by Wyatt Johnson
parent dbbc1af42e
commit 5150cdf60e
136 changed files with 2546 additions and 2465 deletions
@@ -1,24 +1,46 @@
import { merge } from "lodash";
import { pureMerge } from "talk-common/utils";
import { DeepPartial } from "talk-framework/types";
/**
* Fixture prepares schema type to be used in fixtures.
* It adds an optional `__typename` to the schema type and
* marks fields as optional.
* Callbackify turns Fields e.g. `{a: string}`
* to also allow a callback `{a: string | () => string}`
*/
export type Fixture<T> = T extends object
export type Callbackify<T> = T extends object
?
| {
[P in keyof T]: T[P] extends Array<infer U>
? Array<Callbackify<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<Callbackify<V>>
: Callbackify<T[P]>
}
| (() => {
[P in keyof T]: T[P] extends Array<infer U>
? Array<Callbackify<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<Callbackify<V>>
: Callbackify<T[P]>
})
: T | (() => T);
/**
* WithTypename adds `__typename` to allowed props deeply.
*/
export type WithTypename<T> = T extends object
? {
// (cvle): We don't use & { __typename?: string } because for some reason
// typescript would allow field names that are not defined!
[P in keyof T | "__typename"]?: P extends keyof T
? T[P] extends Array<infer U>
? Array<Fixture<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<Fixture<V>>
: Fixture<T[P]>
: string
}
[P in keyof T]: T[P] extends Array<infer U>
? Array<WithTypename<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<WithTypename<V>>
: WithTypename<T[P]>
} & { __typename?: string }
: T;
/**
* Fixture adds typenames and is deeply partial.
*/
export type Fixture<T> = DeepPartial<WithTypename<T>>;
/**
* createFixture lets you input the data of a schema object as deep partial
* including it's `__typename`, merged it with `base` and return it as the
@@ -26,9 +48,12 @@ export type Fixture<T> = T extends object
* to only include fields that exists in `data` and `base` though to
* type this it seems we need partial generic inferation support.
*/
export default function createFixture<T>(data: Fixture<T>, base?: T): T {
export default function createFixture<T>(
data: Fixture<T>,
base?: T
): WithTypename<T> {
if (base) {
return merge({}, base, data);
return pureMerge(base, data) as any;
}
return data as T;
return data as any;
}
@@ -1,4 +1,4 @@
import createFixture, { Fixture } from "./createFixture";
import createFixture, { Fixture, WithTypename } from "./createFixture";
/**
* createFixtures lets you input an array of data of a schema object as deep partial
@@ -10,6 +10,6 @@ import createFixture, { Fixture } from "./createFixture";
export default function createFixtures<T>(
data: Array<Fixture<T>>,
base?: T
): T[] {
return data.map(d => createFixture(d, base)) as T[];
): Array<WithTypename<T>> {
return data.map(d => createFixture(d, base)) as any;
}
@@ -1,16 +1,36 @@
import { identity, omit } from "lodash";
import sinon from "sinon";
import { Mutation } from "talk-framework/lib/relay/mutation";
import { Omit } from "talk-framework/types";
import { Fixture } from "./createFixture";
import { Resolver } from "./createTestRenderer";
export type NoClientMutationID<T> = T extends { clientMutationId: any }
? Omit<T, "clientMutationId">
: T;
export type MutationResult<T> = NoClientMutationID<Fixture<T>>;
export type MutationResultVariations<T> = T extends Resolver<any, infer R>
? MutationResult<R>
: never;
export type MutationResolverCallback<T extends Resolver<any, any>> = (data: {
variables: T extends Resolver<infer V, any>
? V extends { input: infer W }
? NoClientMutationID<W>
: never
: never;
callCount: number;
typecheck: (data: MutationResultVariations<T>) => MutationResultVariations<T>;
}) => MutationResultVariations<T>;
/**
* createMutationResolverStub makes it easier to write a SinonStub.
* Given a `ResolverType` from the Schema it'll provide types as well!.
*/
export default function createMutationResolverStub<
T extends Mutation<any, any, any>
>(
callback: (
variables: T extends Mutation<any, infer I, any> ? I : never,
callCount: number
) => T extends Mutation<any, any, infer R>
? R extends Promise<infer U> ? U | R : R | Promise<R>
: never
) {
T extends Resolver<any, any>
>(callback: MutationResolverCallback<T>) {
let callCount = 0;
const lastClientMutationIds: any[] = [];
const resolver = async (_: any, data: any) => {
@@ -18,7 +38,11 @@ export default function createMutationResolverStub<
expectAndFail(clientMutationId).toBeTruthy();
expectAndFail(lastClientMutationIds).not.toContain(clientMutationId);
lastClientMutationIds.push(clientMutationId);
const result = await callback(data.input, callCount++);
const result = await callback({
variables: omit(data.input, "clientMutationId"),
callCount: callCount++,
typecheck: identity,
});
expectAndFail(result.clientMutationId).toBeUndefined();
result.clientMutationId = clientMutationId;
return result;
@@ -1,17 +1,33 @@
import { identity } from "lodash";
import sinon from "sinon";
type Resolver<V, R> = (parent: any, args: V, context: any, info: any) => R;
import { Fixture } from "./createFixture";
import { Resolver } from "./createTestRenderer";
export type QueryResult<T> = Fixture<T>;
export type QueryResultVariations<
T extends Resolver<any, any>
> = T extends Resolver<any, infer R> ? QueryResult<R> : never;
export type QueryResolverCallback<T extends Resolver<any, any>> = (data: {
variables: T extends Resolver<infer V, any> ? V : never;
callCount: number;
typecheck: (data: QueryResultVariations<T>) => QueryResultVariations<T>;
}) => QueryResultVariations<T>;
/**
* createQueryResolverStub makes it easier to write a SinonStub.
* Given a `ResolverType` from the Schema it'll provide types as well!.
*/
export default function createQueryResolverStub<T extends Resolver<any, any>>(
callback: (
variables: T extends Resolver<infer V, any> ? V : never,
callCount: number
) => T extends Resolver<any, infer R>
? R extends Promise<infer U> ? U | R : R | Promise<R>
: never
callback: QueryResolverCallback<T>
) {
let callCount = 0;
return sinon
.stub()
.callsFake((_: any, data: any) => callback(data, callCount++));
return sinon.stub().callsFake((fallback: any, variables: any) => {
return callback({
variables: variables || fallback,
callCount: callCount++,
typecheck: identity,
});
});
}
@@ -28,7 +28,7 @@ export interface CreateRelayEnvironmentNetworkParams {
/** project name of graphql-config */
projectName: string;
/** graphql resolvers */
resolvers: IResolvers<any, any>;
resolvers?: IResolvers<any, any>;
/** If enabled, graphql responses will be logged to the console */
logNetwork?: boolean;
/** If enabled, graphql errors will be muted */
@@ -99,7 +99,7 @@ export default function createRelayEnvironment(
if (params.network) {
const schema = loadSchema(
params.network.projectName,
params.network.resolvers,
params.network.resolvers || {},
{ requireResolversForResolveType: false }
);
network = Network.create(
@@ -0,0 +1,84 @@
import { mapValues } from "lodash";
import { SinonStub } from "sinon";
import createMutationResolverStub, {
MutationResolverCallback,
} from "./createMutationResolverStub";
import createQueryResolverStub, {
QueryResolverCallback,
} from "./createQueryResolverStub";
import { Resolvers } from "./createTestRenderer";
export interface ResolversTemplate<T extends Resolvers = any> {
Query?: {
[P in keyof Required<T>["Query"]]: QueryResolverCallback<
Required<T>["Query"][P]
>
};
Mutation?: {
[P in keyof Required<T>["Mutation"]]: MutationResolverCallback<
Required<T>["Mutation"][P]
>
};
}
export interface ResolversStub<T extends Resolvers = any> {
Query?: { [P in keyof Required<T>["Query"]]: SinonStub };
Mutation?: { [P in keyof Required<T>["Mutation"]]: SinonStub };
}
function isSinonStub(v: any) {
return v.called !== undefined;
}
/**
* createResolversStub is a helper for creating resolvers.
*
* Instead of writing:
* ```ts
* {
* Query: {
* settings: createQueryResolverStub<QueryToSettingsResolver>(({ variables }) => settings)
* users: createQueryResolverStub<QueryToUsersResolver>(() => users)
* viewer: createQueryResolverStub<QueryToSettingsResolver>(() => viewer)
* },
* Mutation: {
* // Same goes for Mutations
* }
* },
* ```
*
* You can do
* ```ts
* createResolversStub<GQLResolver>({
* Query: {
* settings: ({ variables }) => settings
* users: () => users
* viewer: () => viewer
* },
* Mutation: {
* // Same goes for Mutations
* }
* }),
* ```
*/
export default function createResolversStub<T extends Resolvers = any>(
resolvers: ResolversTemplate<T>
): ResolversStub<T> {
const result: any = {};
if (resolvers.Query) {
result.Query = mapValues(resolvers.Query, v =>
typeof v === "function" && !isSinonStub(v)
? createQueryResolverStub(v)
: v
);
}
if (resolvers.Mutation) {
result.Mutation = mapValues(resolvers.Mutation, v =>
typeof v === "function" && !isSinonStub(v)
? createMutationResolverStub(v)
: v
);
}
return result;
}
@@ -0,0 +1,107 @@
import { EventEmitter2 } from "eventemitter2";
import { IResolvers } from "graphql-tools";
import { noop } from "lodash";
import path from "path";
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createPromisifiedStorage } from "talk-framework/lib/storage";
import { createUUIDGenerator } from "talk-framework/testHelpers";
import createFluentBundle from "./createFluentBundle";
import createRelayEnvironment from "./createRelayEnvironment";
export type Resolver<V, R> = (
parent: any,
args: V,
context: any,
info: any
) => R;
export interface Resolvers<Q extends Resolver<any, any> = any, M = any> {
Query?: Q;
Mutation?: M;
}
export interface TestResolvers<T extends Resolvers = any> {
Query?: { [P in keyof Required<T>["Query"]]: (() => any) };
Mutation?: { [P in keyof Required<T>["Mutation"]]: (() => any) };
}
function createNodeMock(element: React.ReactElement<any>) {
if (element.type === "div") {
return {
innerHtml: "",
className: "",
focus: noop,
};
}
return null;
}
export interface CreateTestRendererParams<T extends Resolvers = any> {
logNetwork?: boolean;
muteNetworkErrors?: boolean;
resolvers?: TestResolvers<T>;
browserInfo?: TalkContext["browserInfo"];
initLocalState?: (
local: RecordProxy,
source: RecordSourceProxy,
environment: Environment
) => void;
}
export default function createTestRenderer<
T extends { Query?: any; Mutation?: any } = any
>(
target: string,
element: React.ReactNode,
params: CreateTestRendererParams<T>
) {
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: params.logNetwork,
resolvers: params.resolvers as IResolvers<any, any>,
muteNetworkErrors: params.muteNetworkErrors,
projectName: "tenant",
},
initLocalState: (localRecord, source, env) => {
if (params.initLocalState) {
params.initLocalState(localRecord, source, env);
}
},
});
const context: TalkContext = {
relayEnvironment: environment,
locales: ["en-US"],
localeBundles: [
createFluentBundle(
target,
path.resolve(__dirname, "../../../../locales/en-US")
),
],
localStorage: createPromisifiedStorage(),
sessionStorage: createPromisifiedStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
browserInfo: params.browserInfo || { ios: false },
uuidGenerator: createUUIDGenerator(),
eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
clearSession: () => Promise.resolve(),
};
let testRenderer: ReactTestRenderer;
TestRenderer.act(() => {
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>{element}</TalkContextProvider>,
{ createNodeMock }
);
});
return { context, testRenderer: testRenderer! };
}
+10 -2
View File
@@ -11,7 +11,6 @@ export {
export { default as createUUIDGenerator } from "./createUUIDGenerator";
export * from "./denormalize";
export { default as limitSnapshotTo } from "./limitSnapshotTo";
export { default as inputPredicate } from "./inputPredicate";
export { default as within } from "./within";
export { default as wait } from "./wait";
export { default as waitForElement } from "./waitForElement";
@@ -22,9 +21,18 @@ export { default as replaceHistoryLocation } from "./replaceHistoryLocation";
export { default as createAccessToken } from "./createAccessToken";
export { default as findParentsWithType } from "./findParentsWithType";
export { default as findParentWithType } from "./findParentWithType";
export { default as createFixture } from "./createFixture";
export {
default as createFixture,
Fixture,
WithTypename,
} from "./createFixture";
export { default as createFixtures } from "./createFixtures";
export {
default as createMutationResolverStub,
} from "./createMutationResolverStub";
export { default as createQueryResolverStub } from "./createQueryResolverStub";
export {
default as createTestRenderer,
CreateTestRendererParams,
} from "./createTestRenderer";
export { default as createResolversStub } from "./createResolversStub";
@@ -1,10 +0,0 @@
import { ReactTestInstance } from "react-test-renderer";
const inputPredicate = (nameOrID: string) => (n: ReactTestInstance) => {
return (
[n.props.name, n.props.id].indexOf(nameOrID) > -1 &&
["input", "button"].indexOf(n.type as string) > -1
);
};
export default inputPredicate;
@@ -12,8 +12,8 @@ export type NoFragmentRefs<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs2<U>> // TODO: (cvle) this should normally reference itself but it complains about a circular reference.
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
? ReadonlyArray<NoFragmentRefs2<U>> // TODO: (cvle) this should normally reference itself but it complains about a circular reference.
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
// TODO: (cvle) these NoFragmentRefX are a workaround for above issue
@@ -21,16 +21,16 @@ export type NoFragmentRefs2<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs3<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
? ReadonlyArray<NoFragmentRefs3<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
export type NoFragmentRefs3<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs4<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
? ReadonlyArray<NoFragmentRefs4<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
export type NoFragmentRefs4<T> = T extends object
+12 -18
View File
@@ -1,9 +1,6 @@
import { ReactTestInstance } from "react-test-renderer";
import {
getByID,
queryByID,
} from "./byID";
import { getByID, queryByID } from "./byID";
import {
getAllByLabelText,
getByLabelText,
@@ -17,12 +14,7 @@ import {
queryByTestID,
} from "./byTestID";
import { getAllByText, getByText, queryAllByText, queryByText } from "./byText";
import {
getAllByType,
getByType,
queryAllByType,
queryByType,
} from "./byType";
import { getAllByType, getByType, queryAllByType, queryByType } from "./byType";
import toJSON from "./toJSON";
type Func0<R> = () => R;
@@ -30,13 +22,15 @@ type Func1<A, R> = (a?: A) => R;
type Func2<A, B, R> = (a: A, b?: B) => R;
type Func3<A, B, C, R> = (a: A, b: B, c?: C) => R;
type RemoveFirstArgument<T, R> =
T extends [any, any, any, any?] ? Func3<T[1], T[2], T[3], R> :
T extends [any, any, any?] ? Func2<T[1], T[2], R> :
T extends [any, any?] ? Func1<T[1], R> :
T extends [any] ? Func0<R> :
unknown
;
type RemoveFirstArgument<T, R> = T extends [any, any, any, any?]
? Func3<T[1], T[2], T[3], R>
: T extends [any, any, any?]
? Func2<T[1], T[2], R>
: T extends [any, any?]
? Func1<T[1], R>
: T extends [any]
? Func0<R>
: unknown;
// tslint:disable
// @TODO: currently tslint fails to parse this: `...any[]`.
@@ -66,5 +60,5 @@ export default function within(container: ReactTestInstance) {
queryByType: applyContainer(container, queryByType),
queryAllByType: applyContainer(container, queryAllByType),
toJSON: () => toJSON(container),
}
};
}