Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make test client's fileUploadMutation work for more input variable shapes #3188

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"build": "tsc -p ./tsconfig.build.json",
"watch": "tsc -p ./tsconfig.build.json -w",
"lint": "eslint --fix .",
"ci": "npm run build"
"ci": "npm run build",
"test": "vitest --config vitest.config.mts --run"
},
"bugs": {
"url": "https://github.com/vendure-ecommerce/vendure/issues"
Expand Down
67 changes: 49 additions & 18 deletions packages/testing/src/simple-graphql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ export class SimpleGraphQLClient {
'Apollo-Require-Preflight': 'true',
};

constructor(private vendureConfig: Required<VendureConfig>, private apiUrl: string = '') {}
constructor(
private vendureConfig: Required<VendureConfig>,
private apiUrl: string = '',
) {}

/**
* @description
Expand Down Expand Up @@ -136,15 +139,13 @@ export class SimpleGraphQLClient {
async asUserWithCredentials(username: string, password: string) {
// first log out as the current user
if (this.authToken) {
await this.query(
gql`
mutation {
logout {
success
}
await this.query(gql`
mutation {
logout {
success
}
`,
);
}
`);
}
const result = await this.query(LOGIN, { username, password });
if (result.login.channels?.length === 1) {
Expand All @@ -170,15 +171,13 @@ export class SimpleGraphQLClient {
* Logs out so that the client is then treated as an anonymous user.
*/
async asAnonymousUser() {
await this.query(
gql`
mutation {
logout {
success
}
await this.query(gql`
mutation {
logout {
success
}
`,
);
}
`);
}

private async makeGraphQlRequest(
Expand Down Expand Up @@ -214,7 +213,36 @@ export class SimpleGraphQLClient {
* Perform a file upload mutation.
*
* Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
*
* Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
*
* @param mutation - GraphQL document for a mutation that has input files
* with the Upload type.
* @param filePaths - Array of paths to files, in the same order that the
* corresponding Upload fields appear in the variables for the mutation.
* @param mapVariables - Function that must return the variables for the
* mutation, with `null` as the value for each `Upload` field.
*
* @example
* // Testing a custom mutation:
* const result = await client.fileUploadMutation({
* mutation: gql`
* mutation AddSellerImages($input: AddSellerImagesInput!) {
* addSellerImages(input: $input) {
* id
* name
* }
* }
* `,
* filePaths: ['./images/profile-picture.jpg', './images/logo.png'],
* mapVariables: () => ({
* name: "George's Pans",
* profilePicture: null, // corresponds to filePaths[0]
* branding: {
* logo: null // corresponds to filePaths[1]
* }
* })
* });
*/
async fileUploadMutation(options: {
mutation: DocumentNode;
Expand Down Expand Up @@ -256,7 +284,10 @@ export class SimpleGraphQLClient {
}

export class ClientError extends Error {
constructor(public response: any, public request: any) {
constructor(
public response: any,
public request: any,
) {
super(ClientError.extractMessage(response));
}
private static extractMessage(response: any): string {
Expand Down
70 changes: 59 additions & 11 deletions packages/testing/src/utils/create-upload-post-data.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import gql from 'graphql-tag';
import { describe, it, assert } from 'vitest';

import { createUploadPostData } from './create-upload-post-data';

Expand All @@ -8,8 +9,16 @@ describe('createUploadPostData()', () => {
gql`
mutation CreateAssets($input: [CreateAssetInput!]!) {
createAssets(input: $input) {
id
name
... on Asset {
id
name
}
... on MimeTypeError {
errorCode
message
fileName
mimeType
}
}
}
`,
Expand All @@ -19,15 +28,18 @@ describe('createUploadPostData()', () => {
}),
);

expect(result.operations.operationName).toBe('CreateAssets');
expect(result.operations.variables).toEqual({
assert.equal(result.operations.operationName, 'CreateAssets');
assert.deepEqual(result.operations.variables, {
input: [{ file: null }, { file: null }],
});
expect(result.map).toEqual({
assert.deepEqual(result.map, {
0: 'variables.input.0.file',
1: 'variables.input.1.file',
});
expect(result.filePaths).toEqual([{ name: '0', file: 'a.jpg' }, { name: '1', file: 'b.jpg' }]);
assert.deepEqual(result.filePaths, [
{ name: '0', file: 'a.jpg' },
{ name: '1', file: 'b.jpg' },
]);
});

it('creates correct output for importProducts mutation', () => {
Expand All @@ -36,19 +48,55 @@ describe('createUploadPostData()', () => {
mutation ImportProducts($input: Upload!) {
importProducts(csvFile: $input) {
errors
importedCount
imported
}
}
`,
'data.csv',
() => ({ csvFile: null }),
);

expect(result.operations.operationName).toBe('ImportProducts');
expect(result.operations.variables).toEqual({ csvFile: null });
expect(result.map).toEqual({
assert.equal(result.operations.operationName, 'ImportProducts');
assert.deepEqual(result.operations.variables, { csvFile: null });
assert.deepEqual(result.map, {
0: 'variables.csvFile',
});
expect(result.filePaths).toEqual([{ name: '0', file: 'data.csv' }]);
assert.deepEqual(result.filePaths, [{ name: '0', file: 'data.csv' }]);
});

it('creates correct output for a mutation with nested Upload and non-Upload fields', () => {
// this is not meant to be a real mutation; it's just an example of one
// that could exist
const result = createUploadPostData(
gql`
mutation ComplexUpload($input: ComplexTypeIncludingUpload!) {
complexUpload(input: $input) {
results
errors
}
}
`,
// the two file paths that are specified must appear in the same
// order as the `null` variables that stand in for the Upload fields
['logo.png', 'profilePicture.jpg'],
() => ({ name: 'George', sellerLogo: null, someOtherThing: { profilePicture: null } }),
);

assert.equal(result.operations.operationName, 'ComplexUpload');
assert.deepEqual(result.operations.variables, {
name: 'George',
sellerLogo: null,
someOtherThing: { profilePicture: null },
});
// `result.map` should map `result.filePaths` onto the Upload fields
// implied by `variables`
assert.deepEqual(result.map, {
0: 'variables.sellerLogo',
1: 'variables.someOtherThing.profilePicture',
});
assert.deepEqual(result.filePaths, [
{ name: '0', file: 'logo.png' },
{ name: '1', file: 'profilePicture.jpg' },
]);
});
});
105 changes: 80 additions & 25 deletions packages/testing/src/utils/create-upload-post-data.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,72 @@
import { DocumentNode, Kind, OperationDefinitionNode, print } from 'graphql';

export interface FilePlaceholder {
file: null;
}
export interface UploadPostData<V = any> {
/**
* Data from a GraphQL document that takes the Upload type as input
*/
operations: {
operationName: string;
variables: V;
query: string;
};

/**
* A map from index values to variable paths. Maps files in the `filePaths`
* array to fields with the Upload type in the GraphQL mutation input.
*
* If this was the GraphQL mutation input type:
* ```graphql
* input ImageReceivingInput {
* bannerImage: Upload!
* logo: Upload!
* }
* ```
*
* And this was the GraphQL mutation:
* ```graphql
* addSellerImages(input: ImageReceivingInput!): Seller
* ```
*
* Then this would be the value for `map`:
* ```js
* {
* 0: 'variables.input.bannerImage',
* 1: 'variables.input.logo'
* }
* ```
*/
map: {
[index: number]: string;
};

/**
* Array of file paths. Mapped to a GraphQL mutation input variable by
* `map`.
*/
filePaths: Array<{
/**
* Index of the file path as a string.
*/
name: string;
/**
* The actual file path
*/
file: string;
}>;
}

/**
* Creates a data structure which can be used to mae a curl request to upload files to a mutation using
* the Upload type.
* Creates a data structure which can be used to make a POST request to upload
* files to a mutation using the Upload type.
*
* @param mutation - The GraphQL document for a mutation that takes an Upload
* type as an input
* @param filePaths - Either a single path or an array of paths to the files
* that should be uploaded
* @param mapVariables - A function that will receive `filePaths` and return an
* object containing the input variables for the mutation, where every field
* with the Upload type has the value `null`.
* @returns an UploadPostData object.
*/
export function createUploadPostData<P extends string[] | string, V>(
mutation: DocumentNode,
Expand All @@ -40,9 +85,7 @@ export function createUploadPostData<P extends string[] | string, V>(
variables,
query: print(mutation),
},
map: filePathsArray.reduce((output, filePath, i) => {
return { ...output, [i.toString()]: objectPath(variables, i).join('.') };
}, {} as Record<number, string>),
map: objectPath(variables).reduce((acc, path, i) => ({ ...acc, [i.toString()]: path }), {}),
filePaths: filePathsArray.map((filePath, i) => ({
name: i.toString(),
file: filePath,
Expand All @@ -51,23 +94,35 @@ export function createUploadPostData<P extends string[] | string, V>(
return postData;
}

function objectPath(variables: any, i: number): Array<string | number> {
const path: Array<string | number> = ['variables'];
let current = variables;
while (current !== null) {
const props = Object.getOwnPropertyNames(current);
if (props) {
const firstProp = props[0];
const val = current[firstProp];
if (Array.isArray(val)) {
path.push(firstProp);
path.push(i);
current = val[0];
} else {
path.push(firstProp);
current = val;
/**
* This function visits each property in the `variables` object, including
* nested ones, and returns the path of each null value, in order.
*
* @example
* // variables:
* {
* input: {
* name: "George's Pots and Pans",
* logo: null,
* user: {
* profilePicture: null
* }
* }
* }
* // return value:
* ['variables.input.logo', 'variables.input.user.profilePicture']
*/
function objectPath(variables: any): string[] {
const pathsToNulls: string[] = [];
const checkValue = (pathSoFar: string, value: any) => {
if (value === null) {
pathsToNulls.push(pathSoFar);
} else if (typeof value === 'object') {
for (const key of Object.getOwnPropertyNames(value)) {
checkValue(`${pathSoFar}.${key}`, value[key]);
}
}
}
return path;
};
checkValue('variables', variables);
return pathsToNulls;
}
18 changes: 18 additions & 0 deletions packages/testing/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';

export default defineConfig({
plugins: [
// SWC required to support decorators used in test plugins
// See https://github.com/vitest-dev/vitest/issues/708#issuecomment-1118628479
// Vite plugin
swc.vite({
jsc: {
transform: {
// See https://github.com/vendure-ecommerce/vendure/issues/2099
useDefineForClassFields: false,
},
},
}),
],
});
Loading