Browse Source

fix(testing): Make test client's `fileUploadMutation` work for more input variable shapes (#3188)

Mitch J. 1 year ago
parent
commit
a8938f4099

+ 2 - 1
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"

+ 49 - 18
packages/testing/src/simple-graphql-client.ts

@@ -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
@@ -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 {

+ 59 - 11
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' },
+        ]);
     });
 });

+ 80 - 25
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<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,
@@ -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,
@@ -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 - 0
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,
+                },
+            },
+        }),
+    ],
+});