From f1096f667888ce6ca858dddc2eeae41191274081 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Thu, 28 Dec 2023 16:54:44 +0100 Subject: [PATCH] Add PDF service in E2E (#8909) * test: Add PDF service in E2E * test(e2e): change PDF approach to work with headless browsers --- .github/workflows/ci.yml | 1 + .github/workflows/e2e.yml | 40 ++++++++++++++++++- .gitignore | 2 + components/CreateGiftCardsSuccess.js | 11 ++++- components/expenses/ExpenseAttachedFiles.js | 1 + .../expenses/ExpenseMoreActionsButton.js | 1 + components/transactions/TransactionDetails.js | 8 +++- cypress.config.js | 3 ++ lib/transactions.js | 4 +- lib/url-helpers.js | 10 ++--- package-lock.json | 29 ++++++++++++++ package.json | 1 + scripts/run_e2e_tests.sh | 29 ++++++++++---- .../02-collective.transactions.test.js | 18 +++++++++ ...min.test.js => 09-giftcards-admin.test.js} | 6 +++ test/cypress/integration/27-expenses.test.js | 22 +++++++++- .../scripts/get-text-from-pdf-content.ts | 5 +++ test/cypress/support/commands.js | 11 +++++ 18 files changed, 182 insertions(+), 20 deletions(-) rename test/cypress/integration/{09-virtualcards-admin.test.js => 09-giftcards-admin.test.js} (92%) create mode 100644 test/cypress/scripts/get-text-from-pdf-content.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fc6ff89fed..ce310f0c1dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ env: NODE_ENV: test WEBSITE_URL: http://localhost:3000 API_URL: http://localhost:3060 + PDF_SERVICE_URL: http://localhost:3002 API_KEY: dvl-1510egmf4a23d80342403fb599qd CI: true diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index afaedb973aa..5752b323851 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -8,6 +8,8 @@ env: OC_ENV: ci NODE_ENV: test WEBSITE_URL: http://localhost:3000 + IMAGES_URL: http://localhost:3001 + PDF_SERVICE_URL: http://localhost:3002 API_URL: http://localhost:3060 API_KEY: dvl-1510egmf4a23d80342403fb599qd CI: true @@ -15,7 +17,6 @@ env: E2E_TEST: 1 PGHOST: localhost PGUSER: postgres - IMAGES_URL: http://localhost:3001 CYPRESS_RECORD: false CYPRESS_VIDEO: false CYPRESS_VIDEO_UPLOAD_ON_PASSES: false @@ -25,6 +26,7 @@ env: FRONTEND_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend API_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend/opencollective-api IMAGES_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend/opencollective-images + PDF_FOLDER: /home/runner/work/opencollective-frontend/opencollective-frontend/opencollective-pdf TERM: xterm CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }} @@ -129,6 +131,12 @@ jobs: repository: opencollective/opencollective-images path: opencollective-images + - name: Checkout (PDF) + uses: actions/checkout@v4 + with: + repository: opencollective/opencollective-pdf + path: opencollective-pdf + # Prepare API - name: Restore node_modules (api) @@ -165,6 +173,24 @@ jobs: working-directory: opencollective-images run: npm run build + # Prepare PDF + + - name: Restore node_modules (pdf) + uses: actions/cache@v3 + id: pdf-node-modules + with: + path: opencollective-pdf/node_modules + key: ${{ runner.os }}-pdf-node-modules-${{ hashFiles('opencollective-pdf/package-lock.json') }} + + - name: Install dependencies (pdf) + working-directory: opencollective-pdf + if: steps.pdf-node-modules.outputs.cache-hit != 'true' + run: npm ci --prefer-offline --no-audit + + - name: Build (pdf) + working-directory: opencollective-pdf + run: npm run build + # Prepare Frontend - name: Restore node_modules (frontend) @@ -210,13 +236,18 @@ jobs: if: steps.next-build.outputs.cache-hit != 'true' run: npm run build + # Seed DB + - name: Setup DB run: ./scripts/setup_db.sh + # Run tests + - name: Run E2E with Cypress run: ./scripts/run_e2e_tests.sh env: CYPRESS_TEST_FILES: ${{ matrix.files }} + - name: Archive test recordings uses: actions/upload-artifact@v3 with: @@ -226,5 +257,12 @@ jobs: test/cypress/videos if: ${{ failure() }} + - name: Archive download folder + uses: actions/upload-artifact@v3 + with: + name: downloads + path: test/cypress/downloads + if: ${{ failure() }} + - name: Report Coverage run: npm run test:coverage diff --git a/.gitignore b/.gitignore index 30b475c482d..6efa9b865cb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules npm-debug.log.* ./report.*.json *.log +logs yarn.lock .DS_Store build @@ -12,6 +13,7 @@ coverage .nyc_output test/cypress/screenshots test/cypress/videos +test/cypress/downloads dist stage *.swp diff --git a/components/CreateGiftCardsSuccess.js b/components/CreateGiftCardsSuccess.js index bf3125c18ca..efed638efab 100644 --- a/components/CreateGiftCardsSuccess.js +++ b/components/CreateGiftCardsSuccess.js @@ -68,7 +68,7 @@ export default class CreateGiftCardsSuccess extends React.Component { }; renderManualSuccess() { - const filename = `${this.props.collectiveSlug}-giftcards-${Date.now()}.pdf`; + const filename = `${this.props.collectiveSlug}-giftcards.pdf`; const downloadUrl = giftCardsDownloadUrl(filename); return ( @@ -105,7 +105,14 @@ export default class CreateGiftCardsSuccess extends React.Component { })} > {({ loading, downloadFile }) => ( - +   diff --git a/components/expenses/ExpenseAttachedFiles.js b/components/expenses/ExpenseAttachedFiles.js index 9af51a5f128..471b0728bef 100644 --- a/components/expenses/ExpenseAttachedFiles.js +++ b/components/expenses/ExpenseAttachedFiles.js @@ -25,6 +25,7 @@ const ExpenseAttachedFiles = ({ files, onRemove, openFileViewer }) => { fileSize={file.info?.size} showFileName openFileViewer={openFileViewer} + data-cy="download-expense-invoice-btn" /> ) : ( diff --git a/components/expenses/ExpenseMoreActionsButton.js b/components/expenses/ExpenseMoreActionsButton.js index 3727aeaf2ac..c02102c5180 100644 --- a/components/expenses/ExpenseMoreActionsButton.js +++ b/components/expenses/ExpenseMoreActionsButton.js @@ -212,6 +212,7 @@ const ExpenseMoreActionsButton = ({ loading={isLoading} onClick={downloadInvoice} disabled={processExpense.loading || isDisabled} + data-cy="download-expense-invoice-btn" > {isLoading ? ( diff --git a/components/transactions/TransactionDetails.js b/components/transactions/TransactionDetails.js index f7c5b0be627..ca06dffc58d 100644 --- a/components/transactions/TransactionDetails.js +++ b/components/transactions/TransactionDetails.js @@ -320,14 +320,20 @@ const TransactionDetails = ({ displayActions, transaction, onMutationSuccess }) {showDownloadInvoiceButton && ( {expense && } {order && } diff --git a/cypress.config.js b/cypress.config.js index ebfc7d354fc..56f60a803dc 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,6 +1,7 @@ // eslint-disable-next-line node/no-unpublished-require const { defineConfig } = require('cypress'); const fs = require('fs'); +const { getTextFromPdfContent } = require('./test/cypress/scripts/get-text-from-pdf-content.ts'); module.exports = defineConfig({ experimentalMemoryManagement: true, @@ -22,6 +23,7 @@ module.exports = defineConfig({ fixturesFolder: 'test/cypress/fixtures', screenshotsFolder: 'test/cypress/screenshots', videosFolder: 'test/cypress/videos', + downloadsFolder: 'test/cypress/downloads', e2e: { setupNodeEvents(on, config) { // eslint-disable-next-line node/no-unpublished-require @@ -39,6 +41,7 @@ module.exports = defineConfig({ console.log(...message); // eslint-disable-line no-console return null; }, + getTextFromPdfContent, }); // Delete videos if the test succeeds diff --git a/lib/transactions.js b/lib/transactions.js index 6cd3e0275dc..f79d0a62101 100644 --- a/lib/transactions.js +++ b/lib/transactions.js @@ -12,7 +12,7 @@ import { formatCurrency } from './currency-utils'; import { toIsoDateStr } from './date-utils'; import { createError, ERROR } from './errors'; import { getFromLocalStorage, LOCAL_STORAGE_KEYS } from './local-storage'; -import { collectiveInvoiceURL, invoiceServiceURL, transactionInvoiceURL } from './url-helpers'; +import { collectiveInvoiceURL, PDF_SERVICE_URL, transactionInvoiceURL } from './url-helpers'; const messages = defineMessages({ hostFee: { @@ -210,7 +210,7 @@ export const saveInvoice = async ({ dateTo, createdAt, }); - const getParams = { format: 'blob', allowExternal: invoiceServiceURL }; + const getParams = { format: 'blob', allowExternal: PDF_SERVICE_URL }; const accessToken = getFromLocalStorage(LOCAL_STORAGE_KEYS.ACCESS_TOKEN); if (accessToken) { getParams.headers = { Authorization: `Bearer ${accessToken}` }; diff --git a/lib/url-helpers.js b/lib/url-helpers.js index c83e7556040..e30260076f4 100644 --- a/lib/url-helpers.js +++ b/lib/url-helpers.js @@ -4,7 +4,7 @@ import { CollectiveType } from './constants/collectives'; import { TransactionTypes } from './constants/transactions'; import { getWebsiteUrl } from './utils'; -export const invoiceServiceURL = process.env.PDF_SERVICE_URL; +export const PDF_SERVICE_URL = process.env.PDF_SERVICE_URL; // ---- Utils ---- @@ -37,15 +37,15 @@ export const objectToQueryString = options => { // ---- Routes to other Open Collective services ---- export const collectiveInvoiceURL = (collectiveSlug, hostSlug, startDate, endDate, format) => { - return `${invoiceServiceURL}/receipts/collectives/${collectiveSlug}/${hostSlug}/${startDate}/${endDate}/receipt.${format}`; + return `${PDF_SERVICE_URL}/receipts/collectives/${collectiveSlug}/${hostSlug}/${startDate}/${endDate}/receipt.${format}`; }; export const transactionInvoiceURL = transactionUUID => { - return `${invoiceServiceURL}/receipts/transactions/${transactionUUID}/receipt.pdf`; + return `${PDF_SERVICE_URL}/receipts/transactions/${transactionUUID}/receipt.pdf`; }; export const expenseInvoiceUrl = expenseId => { - return `${invoiceServiceURL}/expense/${expenseId}/invoice.pdf`; + return `${PDF_SERVICE_URL}/expense/${expenseId}/invoice.pdf`; }; /** @@ -54,7 +54,7 @@ export const expenseInvoiceUrl = expenseId => { * @param {string} filename - filename **with** extension */ export const giftCardsDownloadUrl = filename => { - return `${invoiceServiceURL}/giftcards/from-data/${filename}`; + return `${PDF_SERVICE_URL}/giftcards/from-data/${filename}`; }; // ---- Routes to external services ---- diff --git a/package-lock.json b/package-lock.json index 252abb15049..0c06a0728b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -214,6 +214,7 @@ "node-polyfill-webpack-plugin": "^3.0.0", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", + "pdf-parse": "^1.1.1", "postcss": "8.4.32", "prettier": "3.1.1", "prettier-plugin-tailwindcss": "0.5.9", @@ -28906,6 +28907,12 @@ "node": ">= 0.10.5" } }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "dev": true + }, "node_modules/node-environment-flags": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", @@ -30264,6 +30271,28 @@ "node": ">=0.12" } }, + "node_modules/pdf-parse": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz", + "integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==", + "dev": true, + "dependencies": { + "debug": "^3.1.0", + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + } + }, + "node_modules/pdf-parse/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/pdfjs-dist": { "version": "3.6.172", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.6.172.tgz", diff --git a/package.json b/package.json index ffbcffc2a36..38b2bb210a1 100644 --- a/package.json +++ b/package.json @@ -278,6 +278,7 @@ "node-polyfill-webpack-plugin": "^3.0.0", "npm-run-all": "^4.1.5", "nyc": "^15.1.0", + "pdf-parse": "^1.1.1", "postcss": "8.4.32", "prettier": "3.1.1", "prettier-plugin-tailwindcss": "0.5.9", diff --git a/scripts/run_e2e_tests.sh b/scripts/run_e2e_tests.sh index f92064d05d7..edcd9ea9c07 100755 --- a/scripts/run_e2e_tests.sh +++ b/scripts/run_e2e_tests.sh @@ -1,12 +1,14 @@ #!/bin/bash +mkdir -p logs + echo "> Starting maildev server" npx maildev@2.0.5 & MAILDEV_PID=$! echo "> Starting stripe webhook listener" export STRIPE_WEBHOOK_SIGNING_SECRET=$(stripe --api-key $STRIPE_WEBHOOK_KEY listen --forward-connect-to localhost:3060/webhooks/stripe --print-secret) -stripe --api-key $STRIPE_WEBHOOK_KEY listen --forward-connect-to localhost:3060/webhooks/stripe > /dev/null & +stripe --api-key $STRIPE_WEBHOOK_KEY listen --forward-connect-to localhost:3060/webhooks/stripe >/dev/null & STRIPE_WEBHOOK_PID=$! echo "> Starting api server" @@ -35,10 +37,20 @@ if [ -z "$IMAGES_FOLDER" ]; then else cd $IMAGES_FOLDER fi -npm start & +npm start >../logs/images-service.txt 2>&1 & IMAGES_PID=$! cd - +echo "> Starting PDF server" +if [ -z "$PDF_FOLDER" ]; then + cd ~/pdf +else + cd $PDF_FOLDER +fi +PORT=3002 npm start >../logs/pdf-service.txt 2>&1 & +PDF_PID=$! +cd - + # Set `$CYPRESS_RECORD` to `true` in ENV to activate records if [ "$CYPRESS_RECORD" = "true" ]; then CYPRESS_RECORD="--record" @@ -71,6 +83,8 @@ echo "" wait_for_service Frontend 127.0.0.1 3000 echo "" wait_for_service IMAGES 127.0.0.1 3001 +echo "" +wait_for_service PDF 127.0.0.1 3002 echo "" echo "> Running cypress tests" @@ -80,15 +94,16 @@ npm run cypress:run -- ${CYPRESS_RECORD} --env OC_ENV=$OC_ENV --spec "test/cypre RETURN_CODE=$? if [ $RETURN_CODE -ne 0 ]; then echo "Error with cypress e2e tests, exiting" - exit 1; + exit 1 fi echo "" echo "Killing all node processes" -kill $MAILDEV_PID; +kill $MAILDEV_PID kill $STRIPE_WEBHOOK_PID -kill $API_PID; -kill $FRONTEND_PID; -kill $IMAGES_PID; +kill $API_PID +kill $FRONTEND_PID +kill $IMAGES_PID +kill $PDF_PID echo "Exiting with code $RETURN_CODE" exit $RETURN_CODE diff --git a/test/cypress/integration/02-collective.transactions.test.js b/test/cypress/integration/02-collective.transactions.test.js index e1ccc1065d1..d1bc9599d50 100644 --- a/test/cypress/integration/02-collective.transactions.test.js +++ b/test/cypress/integration/02-collective.transactions.test.js @@ -15,4 +15,22 @@ describe('collective.transactions', () => { cy.visit('/apex/transactions'); cy.get('[data-cy=download-csv]').should('exist'); }); + + it('can download the transaction receipt', () => { + // Default user is an admin of brusselstogether collective + cy.login({ redirect: '/brusselstogether/transactions' }); + cy.contains('button[data-cy=transaction-details]', 'View Details').first().click(); + cy.getByDataCy('download-transaction-receipt-btn').first().click(); + cy.getByDataCy('download-transaction-receipt-btn').first().should('have.attr', 'data-loading', 'true'); // Downloading + cy.getByDataCy('download-transaction-receipt-btn').first().should('have.attr', 'data-loading', 'false'); // Downloaded + const filename = 'brusselstogether_2017-12-04_b961becd-cb85-6c70-6ec5-075151203084.pdf'; + cy.getDownloadedPDFContent(filename) + .should('contain', 'BrusselsTogether ASBL') // Bill from + .should('contain', 'Frederik') // Bill to + .should('contain', 'brusselstogetherasbl_b961becd-cb85-6c70-6ec5-075151203084') + .should('contain', `Contribution #1037`) + .should('contain', '2017-12-04') + .should('contain', 'monthly recurring subscription') + .should('contain', '€10.00'); + }); }); diff --git a/test/cypress/integration/09-virtualcards-admin.test.js b/test/cypress/integration/09-giftcards-admin.test.js similarity index 92% rename from test/cypress/integration/09-virtualcards-admin.test.js rename to test/cypress/integration/09-giftcards-admin.test.js index cd782239481..9a13a545a70 100644 --- a/test/cypress/integration/09-virtualcards-admin.test.js +++ b/test/cypress/integration/09-giftcards-admin.test.js @@ -37,6 +37,12 @@ describe('Gift cards admin', () => { expect(links).to.have.lengthOf(numberOfGiftCards); }); + // Download the PDF + // Mock date to make sure we have the same filename + cy.getByDataCy('download-gift-cards-btn').click(); + const filename = `${collectiveSlug}-giftcards.pdf`; + cy.getDownloadedPDFContent(filename).should('contain', '$542.00 Gift Card from TestOrg'); + // Links should also be added to gift cards list cy.contains('a[href$="/admin/gift-cards"]', 'Back to Gift Cards list').click(); cy.getByDataCy('vc-details').should($giftCards => { diff --git a/test/cypress/integration/27-expenses.test.js b/test/cypress/integration/27-expenses.test.js index d54520c18b3..abed15b54ad 100644 --- a/test/cypress/integration/27-expenses.test.js +++ b/test/cypress/integration/27-expenses.test.js @@ -722,6 +722,7 @@ describe('Expense flow', () => { describe('Actions on expense', () => { let collective; let user; + let expense; let expenseUrl; before(() => { @@ -734,14 +735,31 @@ describe('Expense flow', () => { beforeEach(() => { cy.createExpense({ + type: 'INVOICE', userEmail: user.email, account: { legacyId: collective.id }, payee: { legacyId: user.CollectiveId }, - }).then(expense => (expenseUrl = `/${collective.slug}/expenses/${expense.legacyId}`)); + description: 'Expense for E2E tests', + }).then(createdExpense => { + expense = createdExpense; + expenseUrl = `/${collective.slug}/expenses/${expense.legacyId}`; + }); + }); + + it('Downloads PDF', () => { + cy.login({ email: user.email, redirect: expenseUrl }); + cy.getByDataCy('more-actions').click(); + cy.getByDataCy('download-expense-invoice-btn').click({ force: true }); + const date = new Date(expense.createdAt).toISOString().split('T')[0]; + const filename = `Expense-${expense.legacyId}-${collective.slug}-invoice-${date}.pdf`; + cy.getDownloadedPDFContent(filename) + .should('contain', `Expense #${expense.legacyId}: Expense for E2E tests`) + .should('contain', 'Collective: Test Collective') + .should('contain', '$10.00'); }); it('Approve, unapprove, reject and pay actions on expense', () => { - cy.visit(expenseUrl); + cy.login({ email: user.email, redirect: expenseUrl }); cy.get('[data-cy="expense-status-msg"]').contains('Pending'); cy.getByDataCy('approve-button').click(); cy.get('[data-cy="expense-status-msg"]').contains('Approved'); diff --git a/test/cypress/scripts/get-text-from-pdf-content.ts b/test/cypress/scripts/get-text-from-pdf-content.ts new file mode 100644 index 00000000000..d4ecde043ba --- /dev/null +++ b/test/cypress/scripts/get-text-from-pdf-content.ts @@ -0,0 +1,5 @@ +const pdf = require('pdf-parse'); // eslint-disable-line node/no-unpublished-require + +export const getTextFromPdfContent = (pdfContent: string): Promise => { + return pdf(pdfContent).then(({ text }) => text); +}; diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js index 0a312b065ce..a3d097381c4 100644 --- a/test/cypress/support/commands.js +++ b/test/cypress/support/commands.js @@ -229,6 +229,7 @@ Cypress.Commands.add('createExpense', ({ userEmail = defaultTestUserEmail, accou createExpense(expense: $expense, account: $account) { id legacyId + createdAt account { id slug @@ -613,6 +614,16 @@ Cypress.Commands.add( }, ); +/** + * Wait for a file to be downloaded + */ +Cypress.Commands.add('getDownloadedPDFContent', (filename, options) => { + const downloadFolder = Cypress.config('downloadsFolder'); + cy.readFile(`${downloadFolder}/${filename}`, null, options).then(pdfFileContent => { + cy.task('getTextFromPdfContent', pdfFileContent); + }); +}); + // ---- Private ---- /**