diff --git a/packages/testing/package.json b/packages/testing/package.json index 37c845ef52..11999c1068 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -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" diff --git a/packages/testing/src/simple-graphql-client.ts b/packages/testing/src/simple-graphql-client.ts index 044454c937..cb8010b85e 100644 --- a/packages/testing/src/simple-graphql-client.ts +++ b/packages/testing/src/simple-graphql-client.ts @@ -44,7 +44,10 @@ export class SimpleGraphQLClient { 'Apollo-Require-Preflight': 'true', }; - constructor(private vendureConfig: Required, private apiUrl: string = '') {} + constructor( + private vendureConfig: Required, + private apiUrl: string = '', + ) {} /** * @description @@ -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) { @@ -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( @@ -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; @@ -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 { diff --git a/packages/testing/src/utils/create-upload-post-data.spec.ts b/packages/testing/src/utils/create-upload-post-data.spec.ts index d139b2ada8..3df46ed9a0 100644 --- a/packages/testing/src/utils/create-upload-post-data.spec.ts +++ b/packages/testing/src/utils/create-upload-post-data.spec.ts @@ -1,4 +1,5 @@ import gql from 'graphql-tag'; +import { describe, it, assert } from 'vitest'; import { createUploadPostData } from './create-upload-post-data'; @@ -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 + } } } `, @@ -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', () => { @@ -36,7 +48,7 @@ describe('createUploadPostData()', () => { mutation ImportProducts($input: Upload!) { importProducts(csvFile: $input) { errors - importedCount + imported } } `, @@ -44,11 +56,47 @@ describe('createUploadPostData()', () => { () => ({ 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' }, + ]); }); }); diff --git a/packages/testing/src/utils/create-upload-post-data.ts b/packages/testing/src/utils/create-upload-post-data.ts index f52e760ea9..f1c66cf6fb 100644 --- a/packages/testing/src/utils/create-upload-post-data.ts +++ b/packages/testing/src/utils/create-upload-post-data.ts @@ -1,27 +1,72 @@ import { DocumentNode, Kind, OperationDefinitionNode, print } from 'graphql'; -export interface FilePlaceholder { - file: null; -} export interface UploadPostData { + /** + * 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

( mutation: DocumentNode, @@ -40,9 +85,7 @@ export function createUploadPostData

( variables, query: print(mutation), }, - map: filePathsArray.reduce((output, filePath, i) => { - return { ...output, [i.toString()]: objectPath(variables, i).join('.') }; - }, {} as Record), + map: objectPath(variables).reduce((acc, path, i) => ({ ...acc, [i.toString()]: path }), {}), filePaths: filePathsArray.map((filePath, i) => ({ name: i.toString(), file: filePath, @@ -51,23 +94,35 @@ export function createUploadPostData

( return postData; } -function objectPath(variables: any, i: number): Array { - const path: Array = ['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; } diff --git a/packages/testing/vitest.config.mts b/packages/testing/vitest.config.mts new file mode 100644 index 0000000000..f97b180653 --- /dev/null +++ b/packages/testing/vitest.config.mts @@ -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, + }, + }, + }), + ], +});