Skip to content

Commit

Permalink
fix(testing): Make test client's fileUploadMutation work for more i…
Browse files Browse the repository at this point in the history
…nput variable shapes (#3188)
  • Loading branch information
toBeOfUse authored Nov 8, 2024
1 parent 478989e commit a8938f4
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 55 deletions.
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,
},
},
}),
],
});

0 comments on commit a8938f4

Please sign in to comment.