feat: testing refactor (e2e/int) (#748)
Co-authored-by: James <james@trbl.design> Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
21
.eslintrc.js
@@ -21,6 +21,27 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['test/**/**.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'warn',
|
||||
'jest/prefer-strict-equal': 'off',
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['test/e2e/**/**.ts'],
|
||||
extends: [
|
||||
'plugin:playwright/playwright-test'
|
||||
],
|
||||
rules: {
|
||||
'jest/consistent-test-it': 'off',
|
||||
'jest/require-top-level-describe': 'off',
|
||||
'jest/no-test-callback': 'off',
|
||||
'jest/prefer-strict-equal': 'off',
|
||||
'jest/expect-expect': 'off',
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
||||
21
.github/workflows/tests.yml
vendored
@@ -31,11 +31,26 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- run: yarn build
|
||||
- run: yarn test:client
|
||||
- run: yarn test:int # In-memory db + api tests
|
||||
- run: yarn demo:generate:types
|
||||
|
||||
- name: Component Tests
|
||||
run: yarn test:components
|
||||
- name: Integraion Tests
|
||||
run: yarn test:int
|
||||
- name: Generate Payload Types
|
||||
run: yarn demo:generate:types
|
||||
env:
|
||||
CI: true
|
||||
|
||||
# - name: Install Playwright Browsers
|
||||
# run: npx playwright install --with-deps
|
||||
# - name: E2E Tests
|
||||
# run: yarn test:e2e
|
||||
# - uses: actions/upload-artifact@v2
|
||||
# if: always()
|
||||
# with:
|
||||
# name: playwright-report
|
||||
# path: playwright-report/
|
||||
# retention-days: 30
|
||||
install_npm:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
||||
2
.gitignore
vendored
@@ -2,8 +2,6 @@ coverage
|
||||
package-lock.json
|
||||
dist
|
||||
.idea
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
|
||||
# Created by https://www.gitignore.io/api/node,macos,windows,webstorm,sublimetext,visualstudiocode
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://on.cypress.io/cypress.schema.json",
|
||||
"testFiles": "**/*e2e.ts",
|
||||
"ignoreTestFiles": "**/examples/*spec.js",
|
||||
"viewportWidth": 1440,
|
||||
"viewportHeight": 900,
|
||||
"baseUrl": "http://localhost:3000"
|
||||
}
|
||||
16
cypress/cypress.d.ts
vendored
@@ -1,16 +0,0 @@
|
||||
export { };
|
||||
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable<Subject> {
|
||||
visitAdmin(): Chainable<any>
|
||||
/**
|
||||
* Login
|
||||
*
|
||||
* Creates user if not exists
|
||||
*/
|
||||
login(): Chainable<any>
|
||||
apiLogin(): Chainable<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { adminURL } from './common/constants';
|
||||
import { credentials } from './common/credentials';
|
||||
|
||||
describe('Collections', () => {
|
||||
const collectionName = 'Admins';
|
||||
|
||||
before(() => {
|
||||
cy.apiLogin();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.visitAdmin();
|
||||
});
|
||||
|
||||
it('can view collection', () => {
|
||||
cy.contains(collectionName).click();
|
||||
|
||||
cy.get('.collection-list__wrap')
|
||||
.should('be.visible');
|
||||
cy.get('.collection-list__header')
|
||||
.contains(collectionName)
|
||||
.should('be.visible');
|
||||
|
||||
cy.get('.table')
|
||||
.contains(credentials.email)
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
it('can create new', () => {
|
||||
cy.contains(collectionName).click();
|
||||
|
||||
cy.contains('Create New').click();
|
||||
cy.url().should('contain', `${adminURL}/collections/${collectionName.toLowerCase()}/create`);
|
||||
});
|
||||
it('can create new - plus button', () => {
|
||||
cy.contains(collectionName)
|
||||
.get('.card__actions')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
cy.url().should('contain', `${adminURL}/collections/${collectionName.toLowerCase()}/create`);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export const adminURL = 'http://localhost:3000/admin';
|
||||
@@ -1,5 +0,0 @@
|
||||
export const credentials = {
|
||||
email: 'test@test.com',
|
||||
password: 'test123',
|
||||
roles: ['admin'],
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
describe('Fields', () => {
|
||||
before(() => {
|
||||
cy.apiLogin();
|
||||
});
|
||||
|
||||
describe('Array', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/admin/collections/all-fields/create');
|
||||
});
|
||||
|
||||
it('can add and remove rows', () => {
|
||||
cy.contains('Add Array').click();
|
||||
|
||||
cy.contains('Array Text 1')
|
||||
.should('be.visible');
|
||||
|
||||
cy.get('.action-panel__add-row').first().click();
|
||||
|
||||
cy.get('.field-type.array')
|
||||
.filter(':contains("Editable Array")')
|
||||
.should('contain', '02');
|
||||
|
||||
cy.get('.action-panel__remove-row').first().click();
|
||||
|
||||
cy.get('.field-type.array')
|
||||
.filter(':contains("Editable Array")')
|
||||
.should('not.contain', '02')
|
||||
.should('contain', '01');
|
||||
});
|
||||
|
||||
it('can be readOnly', () => {
|
||||
cy.get('.field-type.array')
|
||||
.filter(':contains("readOnly Array")')
|
||||
.should('not.contain', 'Add Row');
|
||||
|
||||
cy.get('.field-type.array')
|
||||
.filter(':contains("readOnly Array")')
|
||||
.children('.action-panel__add-row')
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('.field-type.array')
|
||||
.filter(':contains("readOnly Array")')
|
||||
.children('.position-panel__move-backward')
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('.field-type.array')
|
||||
.filter(':contains("readOnly Array")')
|
||||
.children('.position-panel__move-forward')
|
||||
.should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin', () => {
|
||||
describe('Conditions', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/admin/collections/conditions/create');
|
||||
});
|
||||
|
||||
it('can see conditional fields', () => {
|
||||
cy.get('#simpleCondition')
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('#customComponent')
|
||||
.should('not.exist');
|
||||
|
||||
cy.contains('Enable Test').click();
|
||||
|
||||
cy.get('#simpleCondition')
|
||||
.should('be.visible');
|
||||
|
||||
cy.get('#customComponent')
|
||||
.should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/* eslint-disable jest/expect-expect */
|
||||
import { adminURL } from './common/constants';
|
||||
import { credentials } from './common/credentials';
|
||||
|
||||
// running login more than one time is not working
|
||||
const viewportSizes: Cypress.ViewportPreset[] = [
|
||||
'macbook-15',
|
||||
// 'iphone-x',
|
||||
// 'ipad-2',
|
||||
];
|
||||
|
||||
// intermittent failure
|
||||
describe.skip('Payload Login', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearCookies();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cy.apiLogin();
|
||||
});
|
||||
|
||||
viewportSizes.forEach((viewportSize) => {
|
||||
describe(`Login (${viewportSize})`, () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(adminURL);
|
||||
});
|
||||
|
||||
it('success', () => {
|
||||
cy.viewport(viewportSize);
|
||||
|
||||
cy.url().should('include', '/admin/login');
|
||||
|
||||
cy.get('.field-type.email input').type(credentials.email);
|
||||
cy.get('.field-type.password input').type(credentials.password);
|
||||
cy.get('form')
|
||||
.contains('form', 'Login')
|
||||
.should('be.visible')
|
||||
.submit();
|
||||
cy.get('.template-default')
|
||||
.find('h3.dashboard__label')
|
||||
.should('have.length', 2); // TODO: Should assert label content
|
||||
cy.url().should('eq', adminURL);
|
||||
});
|
||||
|
||||
// skip due to issue with cookies not being reset between tests
|
||||
it.skip('bad Password', () => {
|
||||
cy.viewport(viewportSize);
|
||||
|
||||
cy.visit(adminURL);
|
||||
cy.get('#email').type(credentials.email);
|
||||
cy.get('#password').type('badpassword');
|
||||
cy.get('form')
|
||||
.contains('form', 'Login')
|
||||
.should('be.visible')
|
||||
.submit();
|
||||
|
||||
cy.get('.Toastify')
|
||||
.contains('The email or password provided is incorrect.')
|
||||
.should('be.visible');
|
||||
});
|
||||
|
||||
// skip due to issue with cookies not being reset between tests
|
||||
it.skip('bad Password - Retry Success', () => {
|
||||
cy.viewport(viewportSize);
|
||||
|
||||
cy.visit(adminURL);
|
||||
cy.get('#email').type(credentials.email);
|
||||
cy.get('#password').type('badpassword');
|
||||
cy.get('form')
|
||||
.contains('form', 'Login')
|
||||
.should('be.visible')
|
||||
.submit();
|
||||
|
||||
cy.get('.Toastify')
|
||||
.contains('The email or password provided is incorrect.')
|
||||
.should('be.visible');
|
||||
|
||||
// Dismiss notification
|
||||
cy.wait(500);
|
||||
cy.get('.Toastify__toast-body').click();
|
||||
cy.wait(200);
|
||||
cy.get('.Toastify__toast-body').should('not.be.visible');
|
||||
cy.url().should('eq', `${adminURL}/login`);
|
||||
|
||||
cy.get('#password').clear().type(credentials.password);
|
||||
cy.get('form')
|
||||
.contains('form', 'Login')
|
||||
.should('be.visible')
|
||||
.submit();
|
||||
|
||||
cy.get('.template-default')
|
||||
.find('h3.dashboard__label')
|
||||
.should('have.length', 2); // TODO: Should assert label content
|
||||
|
||||
cy.url().should('eq', adminURL);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
require('isomorphic-fetch');
|
||||
const { credentials } = require('../integration/common/credentials');
|
||||
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
module.exports = (on) => {
|
||||
on('before:run', () => {
|
||||
return fetch('http://localhost:3000/api/admins/first-register', {
|
||||
body: JSON.stringify(credentials),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'post',
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import { adminURL } from '../integration/common/constants';
|
||||
import { credentials } from '../integration/common/credentials';
|
||||
|
||||
Cypress.Commands.add('visitAdmin', () => {
|
||||
cy.visit(adminURL);
|
||||
});
|
||||
|
||||
Cypress.Commands.add('login', () => {
|
||||
cy.clearCookies();
|
||||
cy.visit(adminURL);
|
||||
cy.get('#email').type(credentials.email);
|
||||
cy.get('#password').type(credentials.password);
|
||||
|
||||
cy.get('body')
|
||||
.then((body) => {
|
||||
if (body.find('.dashboard__card-list').length) {
|
||||
cy.get('.dashboard__card-list')
|
||||
.should('be.visible');
|
||||
}
|
||||
|
||||
if (body.find('#confirm-password').length) {
|
||||
cy.get('#confirm-password').type(credentials.password);
|
||||
cy.get('.rs__indicators').first()
|
||||
.click();
|
||||
cy.get('.rs__menu').first().contains('admin')
|
||||
.click();
|
||||
|
||||
cy.get('form')
|
||||
.contains('form', 'Create')
|
||||
.should('be.visible')
|
||||
.submit();
|
||||
}
|
||||
|
||||
if (body.find('form').length) {
|
||||
cy.get('form')
|
||||
.contains('form', 'Login')
|
||||
.should('be.visible')
|
||||
.submit();
|
||||
}
|
||||
cy.get('.dashboard__card-list')
|
||||
.should('be.visible');
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('apiLogin', () => {
|
||||
cy.api({
|
||||
url: '/api/admins/login',
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
failOnStatusCode: true,
|
||||
}).should(({ status }) => {
|
||||
cy.wrap(status).should('equal', 200);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example support/index.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
import '@bahmutov/cy-api';
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands';
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
|
||||
Cypress.Cookies.defaults({
|
||||
preserve: 'payload-token',
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"es5",
|
||||
"dom"
|
||||
],
|
||||
"types": [
|
||||
"cypress"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,7 @@ module.exports = {
|
||||
verbose: true,
|
||||
testTimeout: 15000,
|
||||
testRegex: '(/src/admin/.*\\.(test|spec))\\.[jt]sx?$',
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/client/globalSetup.js'],
|
||||
setupFilesAfterEnv: ['<rootDir>/test/components/globalSetup.js'],
|
||||
testPathIgnorePatterns: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
@@ -1,11 +1,8 @@
|
||||
module.exports = {
|
||||
verbose: true,
|
||||
testEnvironment: 'node',
|
||||
globalSetup: '<rootDir>/tests/api/globalSetup.ts',
|
||||
testPathIgnorePatterns: [
|
||||
'node_modules',
|
||||
'src/admin/*',
|
||||
'dist',
|
||||
testMatch: [
|
||||
'**/test/int/**/*spec.ts',
|
||||
],
|
||||
testTimeout: 15000,
|
||||
moduleNameMapper: {
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
"node_modules",
|
||||
"node_modules/**/node_modules",
|
||||
"src/admin",
|
||||
"src/**/*.spec.ts",
|
||||
"demo/**/*.tsx"
|
||||
"src/**/*.spec.ts"
|
||||
],
|
||||
"watch": [
|
||||
"src/**/*.ts",
|
||||
"demo/"
|
||||
"test/"
|
||||
],
|
||||
"ext": "ts,js,json",
|
||||
"exec": "node ./demo/index.js"
|
||||
"exec": "node ./test/dev/index.js"
|
||||
}
|
||||
|
||||
21
package.json
@@ -37,14 +37,13 @@
|
||||
"demo:generate:types": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts node dist/bin/generateTypes",
|
||||
"demo:generate:graphqlschema": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts node dist/bin/generateGraphQLSchema",
|
||||
"demo:serve": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts NODE_ENV=production nodemon",
|
||||
"dev": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts nodemon",
|
||||
"test": "yarn test:int && yarn test:client",
|
||||
"pretest": "yarn build",
|
||||
"test:int": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts NODE_ENV=test DISABLE_LOGGING=true jest --forceExit --runInBand",
|
||||
"test:client": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts NODE_ENV=test jest --config=jest.react.config.js",
|
||||
"cy:open": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
"test:e2e": "cross-env DISABLE_LOGGING=true MEMORY_SERVER=true start-server-and-test dev http-get://localhost:3000/admin cy:run",
|
||||
"dev": "nodemon",
|
||||
"test": "yarn test:int && yarn test:components",
|
||||
"test:int": "cross-env NODE_ENV=test DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",
|
||||
"test:e2e": "cross-env NODE_ENV=test DISABLE_LOGGING=true playwright test",
|
||||
"test:e2e:headed": "cross-env NODE_ENV=test DISABLE_LOGGING=true playwright test --headed",
|
||||
"test:e2e:debug": "cross-env PWDEBUG=1 NODE_ENV=test DISABLE_LOGGING=true playwright test",
|
||||
"test:components": "cross-env PAYLOAD_CONFIG_PATH=demo/payload.config.ts NODE_ENV=test jest --config=jest.components.config.js",
|
||||
"clean": "rimraf dist",
|
||||
"release": "release-it",
|
||||
"release:patch": "release-it patch",
|
||||
@@ -192,6 +191,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bahmutov/cy-api": "^2.1.3",
|
||||
"@playwright/test": "^1.23.1",
|
||||
"@release-it/conventional-changelog": "^2.0.0",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^13.0.1",
|
||||
@@ -261,15 +261,16 @@
|
||||
"babel-plugin-ignore-html-and-css-imports": "^0.1.0",
|
||||
"copyfiles": "^2.4.0",
|
||||
"cross-env": "^7.0.2",
|
||||
"cypress": "9.2.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-import": "^2.20.0",
|
||||
"eslint-plugin-jest": "^23.16.0",
|
||||
"eslint-plugin-jest-dom": "^4.0.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.1",
|
||||
"eslint-plugin-playwright": "^0.9.0",
|
||||
"eslint-plugin-react": "^7.18.0",
|
||||
"eslint-plugin-react-hooks": "^2.3.0",
|
||||
"form-data": "^3.0.0",
|
||||
"get-port": "5.1.1",
|
||||
"graphql-request": "^3.4.0",
|
||||
"mongodb": "^3.6.2",
|
||||
"mongodb-memory-server": "^7.2.0",
|
||||
@@ -290,7 +291,7 @@
|
||||
"*.js",
|
||||
"*.d.ts",
|
||||
"!jest.config.js",
|
||||
"!jest.react.config.js"
|
||||
"!jest.components.config.js"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
|
||||
9
playwright.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// playwright.config.ts
|
||||
import type { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
// Look for test files in the "tests" directory, relative to this configuration file
|
||||
testDir: 'test/e2e',
|
||||
workers: 999,
|
||||
};
|
||||
export default config;
|
||||
@@ -213,96 +213,6 @@ describe('Collections - REST', () => {
|
||||
expect(data.doc.nonLocalizedArray[0].localizedEmbeddedText).toStrictEqual('spanish');
|
||||
expect(data.doc.richTextBlocks[0].content[0].children[0].text).toStrictEqual('spanish');
|
||||
});
|
||||
|
||||
it('should persist existing array-based data while updating and passing row ID', async () => {
|
||||
const originalText = 'some optional text';
|
||||
|
||||
const { doc } = await fetch(`${url}/api/arrays`, {
|
||||
headers,
|
||||
method: 'post',
|
||||
body: JSON.stringify({
|
||||
array: [
|
||||
{
|
||||
required: 'a required field here',
|
||||
optional: originalText,
|
||||
},
|
||||
{
|
||||
required: 'another required field here',
|
||||
optional: 'this is cool',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
const arrayWithExistingValues = [
|
||||
...doc.array,
|
||||
];
|
||||
|
||||
const updatedText = 'this is some new text for the first item in array';
|
||||
|
||||
arrayWithExistingValues[0] = {
|
||||
id: arrayWithExistingValues[0].id,
|
||||
required: updatedText,
|
||||
};
|
||||
|
||||
const { doc: updatedDoc } = await fetch(`${url}/api/arrays/${doc.id}`, {
|
||||
headers,
|
||||
method: 'put',
|
||||
body: JSON.stringify({
|
||||
array: arrayWithExistingValues,
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
expect(updatedDoc.array[0].required).toStrictEqual(updatedText);
|
||||
expect(updatedDoc.array[0].optional).toStrictEqual(originalText);
|
||||
});
|
||||
|
||||
it('should disregard existing array-based data while updating and NOT passing row ID', async () => {
|
||||
const updatedText = 'here is some new text';
|
||||
|
||||
const secondArrayItem = {
|
||||
required: 'test',
|
||||
optional: 'optional test',
|
||||
};
|
||||
|
||||
const { doc } = await fetch(`${url}/api/arrays`, {
|
||||
headers,
|
||||
method: 'post',
|
||||
body: JSON.stringify({
|
||||
array: [
|
||||
{
|
||||
required: 'a required field here',
|
||||
optional: 'some optional text',
|
||||
},
|
||||
secondArrayItem,
|
||||
],
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
const { doc: updatedDoc } = await fetch(`${url}/api/arrays/${doc.id}`, {
|
||||
headers,
|
||||
method: 'put',
|
||||
body: JSON.stringify({
|
||||
array: [
|
||||
{
|
||||
required: updatedText,
|
||||
},
|
||||
{
|
||||
id: doc.array[1].id,
|
||||
required: doc.array[1].required,
|
||||
// NOTE - not passing optional field. It should persist
|
||||
// because we're passing ID
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).then((res) => res.json());
|
||||
|
||||
expect(updatedDoc.array[0].required).toStrictEqual(updatedText);
|
||||
expect(updatedDoc.array[0].optional).toBeUndefined();
|
||||
|
||||
expect(updatedDoc.array[1].required).toStrictEqual(secondArrayItem.required);
|
||||
expect(updatedDoc.array[1].optional).toStrictEqual(secondArrayItem.optional);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read', () => {
|
||||
|
||||
@@ -136,5 +136,6 @@ export default joi.object({
|
||||
plugins: joi.array().items(
|
||||
joi.func(),
|
||||
),
|
||||
onInit: joi.func(),
|
||||
debug: joi.boolean(),
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ export type InitOptions = {
|
||||
secret: string;
|
||||
email?: EmailOptions;
|
||||
local?: boolean;
|
||||
onInit?: (payload: Payload) => void;
|
||||
onInit?: (payload: Payload) => Promise<void>;
|
||||
/** Pino LoggerOptions */
|
||||
loggerOptions?: LoggerOptions;
|
||||
};
|
||||
@@ -194,6 +194,7 @@ export type Config = {
|
||||
};
|
||||
plugins?: Plugin[];
|
||||
telemetry?: boolean;
|
||||
onInit?: (payload: Payload) => Promise<void>
|
||||
};
|
||||
|
||||
export type SanitizedConfig = Omit<DeepRequired<Config>, 'collections' | 'globals'> & {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Payload } from '../index';
|
||||
const router = express.Router();
|
||||
|
||||
function initAdmin(ctx: Payload): void {
|
||||
if (!ctx.config.admin.disable && process.env.NODE_ENV !== 'test') {
|
||||
if (!ctx.config.admin.disable) {
|
||||
router.use(history());
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
||||
16
src/index.ts
@@ -101,6 +101,8 @@ export class Payload {
|
||||
|
||||
mongoURL: string | false;
|
||||
|
||||
mongoMemoryServer: any
|
||||
|
||||
local: boolean;
|
||||
|
||||
encrypt = encrypt;
|
||||
@@ -138,7 +140,7 @@ export class Payload {
|
||||
* @description Initializes Payload
|
||||
* @param options
|
||||
*/
|
||||
init(options: InitOptions): void {
|
||||
async init(options: InitOptions): Promise<void> {
|
||||
this.logger = Logger('payload', options.loggerOptions);
|
||||
this.logger.info('Starting Payload...');
|
||||
if (!options.secret) {
|
||||
@@ -178,11 +180,6 @@ export class Payload {
|
||||
initCollections(this);
|
||||
initGlobals(this);
|
||||
|
||||
// Connect to database
|
||||
if (this.mongoURL) {
|
||||
connectMongoose(this.mongoURL, options.mongoOptions, options.local, this.logger);
|
||||
}
|
||||
|
||||
if (!this.config.graphQL.disable) {
|
||||
registerSchema(this);
|
||||
}
|
||||
@@ -225,7 +222,12 @@ export class Payload {
|
||||
this.authenticate = authenticate(this.config);
|
||||
}
|
||||
|
||||
if (typeof options.onInit === 'function') options.onInit(this);
|
||||
// Connect to database
|
||||
if (this.mongoURL) {
|
||||
this.mongoMemoryServer = await connectMongoose(this.mongoURL, options.mongoOptions, this.logger);
|
||||
}
|
||||
if (typeof options.onInit === 'function') await options.onInit(this);
|
||||
if (typeof this.config.onInit === 'function') await this.config.onInit(this);
|
||||
|
||||
serverInitTelemetry(this);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import mongoose, { ConnectOptions } from 'mongoose';
|
||||
import pino from 'pino';
|
||||
import getPort from 'get-port';
|
||||
import { connection } from './testCredentials';
|
||||
|
||||
const connectMongoose = async (
|
||||
url: string,
|
||||
options: ConnectOptions,
|
||||
local: boolean,
|
||||
logger: pino.Logger,
|
||||
): Promise<void> => {
|
||||
): Promise<void | any> => {
|
||||
let urlToConnect = url;
|
||||
let successfulConnectionMessage = 'Connected to Mongo server successfully!';
|
||||
const connectionOptions = {
|
||||
@@ -16,33 +16,42 @@ const connectMongoose = async (
|
||||
useNewUrlParser: true,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === 'test' || process.env.MEMORY_SERVER) {
|
||||
if (local) {
|
||||
urlToConnect = `${connection.url}:${connection.port}/${connection.name}`;
|
||||
} else {
|
||||
let mongoMemoryServer;
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
connectionOptions.dbName = 'payloadmemory';
|
||||
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const mongo = await MongoMemoryServer.create({
|
||||
const port = await getPort();
|
||||
mongoMemoryServer = await MongoMemoryServer.create({
|
||||
instance: {
|
||||
dbName: connection.name,
|
||||
port: connection.port,
|
||||
port,
|
||||
},
|
||||
});
|
||||
|
||||
urlToConnect = mongo.getUri();
|
||||
urlToConnect = mongoMemoryServer.getUri();
|
||||
successfulConnectionMessage = 'Connected to in-memory Mongo server successfully!';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (process.env.PAYLOAD_DROP_DATABASE === 'true') {
|
||||
mongoose.connection.once('connected', () => {
|
||||
logger.info('---- DROPPING DATABASE ----');
|
||||
mongoose.connection.dropDatabase();
|
||||
});
|
||||
}
|
||||
|
||||
await mongoose.connect(urlToConnect, connectionOptions);
|
||||
|
||||
|
||||
logger.info(successfulConnectionMessage);
|
||||
} catch (err) {
|
||||
logger.error(`Error: cannot connect to MongoDB. Details: ${err.message}`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return mongoMemoryServer;
|
||||
};
|
||||
|
||||
export default connectMongoose;
|
||||
|
||||
55
src/tests/helpers/configHelpers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import merge from 'deepmerge';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { CollectionConfig } from '../../collections/config/types';
|
||||
import { Config, SanitizedConfig, InitOptions } from '../../config/types';
|
||||
import { buildConfig } from '../../config/build';
|
||||
import payload from '../..';
|
||||
|
||||
|
||||
const Admins: CollectionConfig = {
|
||||
slug: 'admins',
|
||||
auth: true,
|
||||
fields: [
|
||||
// Email added by default
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const baseConfig: Config = {
|
||||
serverURL: 'http://localhost:3000',
|
||||
admin: {
|
||||
user: Admins.slug,
|
||||
},
|
||||
collections: [
|
||||
Admins,
|
||||
],
|
||||
};
|
||||
|
||||
export function generateTestConfig(overrides?: Partial<Config>): SanitizedConfig {
|
||||
return buildConfig(merge(baseConfig, overrides));
|
||||
}
|
||||
|
||||
type InitPayloadTestOptions = { initOptions?: Partial<InitOptions> }
|
||||
export function initPayloadTest(dirName: string, initOptions?: InitPayloadTestOptions): void {
|
||||
process.env.PAYLOAD_CONFIG_PATH = path.resolve(dirName, './payload.config.ts');
|
||||
|
||||
payload.init({
|
||||
local: true,
|
||||
mongoURL: `mongodb://localhost/${uuid()}`,
|
||||
secret: uuid(),
|
||||
// TODO: Figure out how to handle express
|
||||
...initOptions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export const openAccess: CollectionConfig['access'] = {
|
||||
read: () => true,
|
||||
create: () => true,
|
||||
delete: () => true,
|
||||
update: () => true,
|
||||
};
|
||||
6
src/utilities/mapAsync.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export async function mapAsync<T, U>(
|
||||
arr: T[],
|
||||
callbackfn: (item: T, index: number, array: T[]) => Promise<U>,
|
||||
): Promise<U[]> {
|
||||
return Promise.all(arr.map(callbackfn));
|
||||
}
|
||||
21
test/dev/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const path = require('path');
|
||||
const babelConfig = require('../../babel.config');
|
||||
|
||||
require('@babel/register')({
|
||||
...babelConfig,
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||
env: {
|
||||
development: {
|
||||
sourceMaps: 'inline',
|
||||
retainLines: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [testConfigDir] = process.argv.slice(2);
|
||||
|
||||
const configPath = path.resolve(__dirname, '../', testConfigDir, 'config.ts');
|
||||
process.env.PAYLOAD_CONFIG_PATH = configPath;
|
||||
process.env.PAYLOAD_DROP_DATABASE = 'true';
|
||||
|
||||
require('./server');
|
||||
32
test/dev/server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable no-console */
|
||||
import express from 'express';
|
||||
import payload from '../../src';
|
||||
|
||||
const expressApp = express();
|
||||
|
||||
const init = async () => {
|
||||
await payload.init({
|
||||
secret: 'SECRET_KEY',
|
||||
mongoURL: process.env.MONGO_URL || 'mongodb://localhost/payload',
|
||||
express: expressApp,
|
||||
email: {
|
||||
logMockCredentials: true,
|
||||
fromName: 'Payload',
|
||||
fromAddress: 'hello@payloadcms.com',
|
||||
},
|
||||
onInit: async (app) => {
|
||||
app.logger.info('Payload Dev Server Initialized');
|
||||
},
|
||||
});
|
||||
|
||||
const externalRouter = express.Router();
|
||||
|
||||
externalRouter.use(payload.authenticate);
|
||||
|
||||
expressApp.listen(3000, async () => {
|
||||
payload.logger.info(`Admin URL on ${payload.getAdminURL()}`);
|
||||
payload.logger.info(`API URL on ${payload.getAPIURL()}`);
|
||||
});
|
||||
};
|
||||
|
||||
init();
|
||||
36
test/e2e/access-control/config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export const slug = 'access-controls';
|
||||
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug,
|
||||
fields: [
|
||||
{
|
||||
name: 'restrictedField',
|
||||
type: 'text',
|
||||
access: {
|
||||
read: () => false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'restricted',
|
||||
fields: [],
|
||||
access: {
|
||||
read: () => false,
|
||||
},
|
||||
},
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
collection: slug,
|
||||
data: {
|
||||
restrictedField: 'restricted',
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
82
test/e2e/access-control/index.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import payload from '../../../src';
|
||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||
import { initPayloadE2E } from '../../helpers/configHelpers';
|
||||
import { firstRegister } from '../helpers';
|
||||
import { slug } from './config';
|
||||
|
||||
/**
|
||||
* TODO: Access Control
|
||||
* - [x] restricted collections not shown
|
||||
* - no sidebar link
|
||||
* - no route
|
||||
* - no card
|
||||
* [x] field without read access should not show
|
||||
* prevent user from logging in (canAccessAdmin)
|
||||
* no create controls if no access
|
||||
* no update control if no update
|
||||
* - check fields are rendered as readonly
|
||||
* no delete control if no access
|
||||
* no version controls is no access
|
||||
*
|
||||
* FSK: 'should properly prevent / allow public users from reading a restricted field'
|
||||
*
|
||||
* Repeat all above for globals
|
||||
*/
|
||||
|
||||
const { beforeAll, describe } = test;
|
||||
let url: AdminUrlUtil;
|
||||
|
||||
describe('access control', () => {
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadE2E(__dirname);
|
||||
// await clearDocs(); // Clear any seeded data from onInit
|
||||
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
await firstRegister({ page, serverURL });
|
||||
});
|
||||
|
||||
// afterEach(async () => {
|
||||
// });
|
||||
|
||||
test('field without read access should not show', async () => {
|
||||
const { id } = await createDoc({ restrictedField: 'restricted' });
|
||||
|
||||
await page.goto(url.doc(id));
|
||||
|
||||
await expect(page.locator('input[name="restrictedField"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
describe('restricted collection', () => {
|
||||
test('should not show in card list', async () => {
|
||||
await page.goto(url.admin);
|
||||
await expect(page.locator('.dashboard__card-list >> text=Restricteds')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should not show in nav', async () => {
|
||||
await page.goto(url.admin);
|
||||
await expect(page.locator('.nav >> a:has-text("Restricteds")')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('should not have collection url', async () => {
|
||||
await page.goto(url.collection);
|
||||
await page.locator('text=Nothing found').click();
|
||||
await page.locator('a:has-text("Back to Dashboard")').click();
|
||||
await expect(page).toHaveURL(url.admin);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createDoc(data: any): Promise<{ id: string }> {
|
||||
return payload.create({
|
||||
collection: slug,
|
||||
data,
|
||||
});
|
||||
}
|
||||
19
test/e2e/auth/config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export const slug = 'users';
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: 'users',
|
||||
},
|
||||
collections: [{
|
||||
slug,
|
||||
auth: {
|
||||
verify: true,
|
||||
useAPIKey: true,
|
||||
maxLoginAttempts: 2,
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
69
test/e2e/auth/index.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import { firstRegister } from '../helpers';
|
||||
import { slug } from './config';
|
||||
|
||||
|
||||
/**
|
||||
* TODO: Auth
|
||||
* change password
|
||||
* unlock
|
||||
* generate api key
|
||||
* log out
|
||||
*/
|
||||
|
||||
const { beforeAll, describe } = test;
|
||||
let url: AdminUrlUtil;
|
||||
|
||||
describe('authentication', () => {
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadTest({
|
||||
__dirname,
|
||||
init: {
|
||||
local: false,
|
||||
},
|
||||
});
|
||||
// await clearDocs(); // Clear any seeded data from onInit
|
||||
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
await firstRegister({ page, serverURL });
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
test('should login and logout', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should logout', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should allow change password', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should reset password', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should lockout after reaching max login attempts', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should prevent login for locked user', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should unlock user', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should not login without verify', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
test('should allow generate api keys', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
18
test/e2e/buildConfig.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import merge from 'deepmerge';
|
||||
import { buildConfig as buildPayloadConfig } from '../../src/config/build';
|
||||
import type { Config, SanitizedConfig } from '../../src/config/types';
|
||||
|
||||
export function buildConfig(overrides?: Partial<Config>): SanitizedConfig {
|
||||
const baseConfig: Config = {};
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
baseConfig.admin = {
|
||||
webpack: (config) => ({
|
||||
...config,
|
||||
cache: {
|
||||
type: 'memory',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return buildPayloadConfig(merge(baseConfig, overrides || {}));
|
||||
}
|
||||
39
test/e2e/collections/config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export const slug = 'posts';
|
||||
|
||||
export interface Post {
|
||||
id: string,
|
||||
title: string,
|
||||
description: string,
|
||||
createdAt: Date,
|
||||
updatedAt: Date,
|
||||
}
|
||||
|
||||
export default buildConfig({
|
||||
collections: [{
|
||||
slug,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
}],
|
||||
onInit: async (payload) => {
|
||||
await mapAsync([...Array(11)], async () => {
|
||||
await payload.create({
|
||||
collection: slug,
|
||||
data: {
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
285
test/e2e/collections/index.spec.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import payload from '../../../src';
|
||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import { firstRegister, saveDocAndAssert } from '../helpers';
|
||||
import type { Post } from './config';
|
||||
import { slug } from './config';
|
||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||
import wait from '../../../src/utilities/wait';
|
||||
|
||||
const { afterEach, beforeAll, beforeEach, describe } = test;
|
||||
|
||||
const title = 'title';
|
||||
const description = 'description';
|
||||
|
||||
let url: AdminUrlUtil;
|
||||
|
||||
describe('collections', () => {
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadTest({
|
||||
__dirname,
|
||||
init: {
|
||||
local: false,
|
||||
},
|
||||
});
|
||||
await clearDocs(); // Clear any seeded data from onInit
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
await firstRegister({ page, serverURL });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearDocs();
|
||||
});
|
||||
|
||||
describe('Nav', () => {
|
||||
test('should nav to collection - sidebar', async () => {
|
||||
await page.goto(url.admin);
|
||||
const collectionLink = page.locator(`nav >> text=${slug}`);
|
||||
await collectionLink.click();
|
||||
|
||||
expect(page.url()).toContain(url.collection);
|
||||
});
|
||||
|
||||
test('should navigate to collection - card', async () => {
|
||||
await page.goto(url.admin);
|
||||
await page.locator('a:has-text("Posts")').click();
|
||||
expect(page.url()).toContain(url.collection);
|
||||
});
|
||||
|
||||
test('breadcrumbs - from card to dashboard', async () => {
|
||||
await page.goto(url.collection);
|
||||
await page.locator('a:has-text("Dashboard")').click();
|
||||
expect(page.url()).toContain(url.admin);
|
||||
});
|
||||
|
||||
test('breadcrumbs - from document to collection', async () => {
|
||||
const { id } = await createPost();
|
||||
|
||||
await page.goto(url.doc(id));
|
||||
await page.locator('nav >> text=Posts').click();
|
||||
expect(page.url()).toContain(url.collection);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('CRUD', () => {
|
||||
test('should create', async () => {
|
||||
await page.goto(url.create);
|
||||
await page.locator('#title').fill(title);
|
||||
await page.locator('#description').fill(description);
|
||||
await page.click('text=Save', { delay: 100 });
|
||||
|
||||
await saveDocAndAssert(page);
|
||||
|
||||
await expect(page.locator('#title')).toHaveValue(title);
|
||||
await expect(page.locator('#description')).toHaveValue(description);
|
||||
});
|
||||
|
||||
test('should read existing', async () => {
|
||||
const { id } = await createPost();
|
||||
|
||||
await page.goto(url.doc(id));
|
||||
|
||||
await expect(page.locator('#title')).toHaveValue(title);
|
||||
await expect(page.locator('#description')).toHaveValue(description);
|
||||
});
|
||||
|
||||
test('should update existing', async () => {
|
||||
const { id } = await createPost();
|
||||
|
||||
await page.goto(url.doc(id));
|
||||
|
||||
const newTitle = 'new title';
|
||||
const newDesc = 'new description';
|
||||
await page.locator('#title').fill(newTitle);
|
||||
await page.locator('#description').fill(newDesc);
|
||||
|
||||
await saveDocAndAssert(page);
|
||||
|
||||
await expect(page.locator('#title')).toHaveValue(newTitle);
|
||||
await expect(page.locator('#description')).toHaveValue(newDesc);
|
||||
});
|
||||
|
||||
test('should delete existing', async () => {
|
||||
const { id } = await createPost();
|
||||
|
||||
await page.goto(url.doc(id));
|
||||
await page.locator('button:has-text("Delete")').click();
|
||||
await page.locator('button:has-text("Confirm")').click();
|
||||
|
||||
await expect(page.locator(`text=Post "${id}" successfully deleted.`)).toBeVisible();
|
||||
expect(page.url()).toContain(url.collection);
|
||||
});
|
||||
|
||||
test('should duplicate existing', async () => {
|
||||
const { id } = await createPost();
|
||||
|
||||
await page.goto(url.doc(id));
|
||||
await page.locator('button:has-text("Duplicate")').click();
|
||||
|
||||
expect(page.url()).toContain(url.create);
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
expect(page.url()).not.toContain(id); // new id
|
||||
});
|
||||
});
|
||||
|
||||
describe('list view', () => {
|
||||
const tableRowLocator = 'table >> tbody >> tr';
|
||||
|
||||
beforeEach(async () => {
|
||||
await page.goto(url.collection);
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
test('search by id', async () => {
|
||||
const { id } = await createPost();
|
||||
await page.locator('.search-filter__input').fill(id);
|
||||
const tableItems = page.locator(tableRowLocator);
|
||||
await expect(tableItems).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('toggle columns', async () => {
|
||||
const columnCountLocator = 'table >> thead >> tr >> th';
|
||||
await createPost();
|
||||
await page.locator('button:has-text("Columns")').click();
|
||||
await wait(1000); // Wait for column toggle UI, should probably use waitForSelector
|
||||
|
||||
const numberOfColumns = await page.locator(columnCountLocator).count();
|
||||
const idButton = page.locator('.column-selector >> text=ID');
|
||||
|
||||
// Remove ID column
|
||||
await idButton.click({ delay: 100 });
|
||||
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns - 1);
|
||||
|
||||
// Add back ID column
|
||||
await idButton.click({ delay: 100 });
|
||||
await expect(page.locator(columnCountLocator)).toHaveCount(numberOfColumns);
|
||||
});
|
||||
|
||||
test('filter rows', async () => {
|
||||
const { id } = await createPost({ title: 'post1' });
|
||||
await createPost({ title: 'post2' });
|
||||
|
||||
await expect(page.locator(tableRowLocator)).toHaveCount(2);
|
||||
|
||||
await page.locator('button:has-text("Filters")').click();
|
||||
await wait(1000); // Wait for column toggle UI, should probably use waitForSelector
|
||||
|
||||
await page.locator('text=Add filter').click();
|
||||
|
||||
const operatorField = page.locator('.condition >> .condition__operator');
|
||||
const valueField = page.locator('.condition >> .condition__value >> input');
|
||||
|
||||
await operatorField.click();
|
||||
|
||||
const dropdownOptions = operatorField.locator('.rs__option');
|
||||
await dropdownOptions.locator('text=equals').click();
|
||||
|
||||
await valueField.fill(id);
|
||||
await wait(1000);
|
||||
|
||||
await expect(page.locator(tableRowLocator)).toHaveCount(1);
|
||||
const firstId = await page.locator(tableRowLocator).first().locator('td').first()
|
||||
.innerText();
|
||||
expect(firstId).toEqual(id);
|
||||
|
||||
// Remove filter
|
||||
await page.locator('.condition >> .icon--x').click();
|
||||
await wait(1000);
|
||||
await expect(page.locator(tableRowLocator)).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
beforeAll(async () => {
|
||||
await mapAsync([...Array(11)], async () => {
|
||||
await createPost();
|
||||
});
|
||||
});
|
||||
|
||||
test('should paginate', async () => {
|
||||
const pageInfo = page.locator('.collection-list__page-info');
|
||||
const perPage = page.locator('.per-page');
|
||||
const paginator = page.locator('.paginator');
|
||||
const tableItems = page.locator(tableRowLocator);
|
||||
|
||||
await expect(tableItems).toHaveCount(10);
|
||||
await expect(pageInfo).toHaveText('1-10 of 11');
|
||||
await expect(perPage).toContainText('Per Page: 10');
|
||||
|
||||
// Forward one page and back using numbers
|
||||
await paginator.locator('button').nth(1).click();
|
||||
expect(page.url()).toContain('?page=2');
|
||||
await expect(tableItems).toHaveCount(1);
|
||||
await paginator.locator('button').nth(0).click();
|
||||
expect(page.url()).toContain('?page=1');
|
||||
await expect(tableItems).toHaveCount(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sorting', () => {
|
||||
beforeAll(async () => {
|
||||
[1, 2].map(async () => {
|
||||
await createPost();
|
||||
});
|
||||
});
|
||||
|
||||
test('should sort', async () => {
|
||||
const getTableItems = () => page.locator(tableRowLocator);
|
||||
|
||||
await expect(getTableItems()).toHaveCount(2);
|
||||
|
||||
const chevrons = page.locator('table >> thead >> th >> button');
|
||||
const upChevron = chevrons.first();
|
||||
const downChevron = chevrons.nth(1);
|
||||
|
||||
const getFirstId = async () => getTableItems().first().locator('td').first()
|
||||
.innerText();
|
||||
const getSecondId = async () => getTableItems().nth(1).locator('td').first()
|
||||
.innerText();
|
||||
|
||||
const firstId = await getFirstId();
|
||||
const secondId = await getSecondId();
|
||||
|
||||
await upChevron.click({ delay: 100 });
|
||||
|
||||
// Order should have swapped
|
||||
expect(await getFirstId()).toEqual(secondId);
|
||||
expect(await getSecondId()).toEqual(firstId);
|
||||
|
||||
await downChevron.click({ delay: 100 });
|
||||
|
||||
// Swap back
|
||||
expect(await getFirstId()).toEqual(firstId);
|
||||
expect(await getSecondId()).toEqual(secondId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createPost(overrides?: Partial<Post>): Promise<Post> {
|
||||
return payload.create<Post>({
|
||||
collection: slug,
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function clearDocs(): Promise<void> {
|
||||
const allDocs = await payload.find<Post>({ collection: slug, limit: 100 });
|
||||
const ids = allDocs.docs.map((doc) => doc.id);
|
||||
await mapAsync(ids, async (id) => {
|
||||
await payload.delete({ collection: slug, id });
|
||||
});
|
||||
}
|
||||
35
test/e2e/fields-array/config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export const slug = 'fields-array';
|
||||
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug,
|
||||
fields: [
|
||||
{
|
||||
type: 'array',
|
||||
name: 'readOnlyArray',
|
||||
label: 'readOnly Array',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
defaultValue: [
|
||||
{
|
||||
text: 'defaultValue',
|
||||
},
|
||||
{
|
||||
text: 'defaultValue2',
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
43
test/e2e/fields-array/index.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import wait from '../../../src/utilities/wait';
|
||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import { firstRegister } from '../helpers';
|
||||
import { slug } from './config';
|
||||
|
||||
const { beforeAll, describe } = test;
|
||||
|
||||
let url: AdminUrlUtil;
|
||||
|
||||
describe('fields - array', () => {
|
||||
let page: Page;
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadTest({
|
||||
__dirname,
|
||||
init: {
|
||||
local: false,
|
||||
},
|
||||
});
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
await firstRegister({ page, serverURL });
|
||||
});
|
||||
|
||||
test('should be readOnly', async () => {
|
||||
await page.goto(url.create);
|
||||
await wait(2000);
|
||||
const field = page.locator('#readOnlyArray\\.0\\.text');
|
||||
await expect(field).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should have defaultValue', async () => {
|
||||
await page.goto(url.create);
|
||||
await wait(2000);
|
||||
const field = page.locator('#readOnlyArray\\.0\\.text');
|
||||
await expect(field).toHaveValue('defaultValue');
|
||||
});
|
||||
});
|
||||
142
test/e2e/fields-relationship/config.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { CollectionConfig } from '../../../src/collections/config/types';
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export const slug = 'fields-relationship';
|
||||
|
||||
export const relationOneSlug = 'relation-one';
|
||||
export const relationTwoSlug = 'relation-two';
|
||||
export const relationRestrictedSlug = 'relation-restricted';
|
||||
export const relationWithTitleSlug = 'relation-with-title';
|
||||
|
||||
export interface FieldsRelationship {
|
||||
id: string;
|
||||
relationship: RelationOne;
|
||||
relationshipHasMany: RelationOne[];
|
||||
relationshipMultiple: Array<RelationOne | RelationTwo>;
|
||||
relationshipRestricted: RelationRestricted;
|
||||
relationshipWithTitle: RelationWithTitle;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RelationOne {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
export type RelationTwo = RelationOne;
|
||||
export type RelationRestricted = RelationOne;
|
||||
export type RelationWithTitle = RelationOne;
|
||||
|
||||
const baseRelationshipFields: CollectionConfig['fields'] = [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug,
|
||||
fields: [
|
||||
{
|
||||
type: 'relationship',
|
||||
name: 'relationship',
|
||||
relationTo: relationOneSlug,
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
name: 'relationshipHasMany',
|
||||
relationTo: relationOneSlug,
|
||||
hasMany: true,
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
name: 'relationshipMultiple',
|
||||
relationTo: [relationOneSlug, relationTwoSlug],
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
name: 'relationshipRestricted',
|
||||
relationTo: relationRestrictedSlug,
|
||||
},
|
||||
{
|
||||
type: 'relationship',
|
||||
name: 'relationshipWithTitle',
|
||||
relationTo: relationWithTitleSlug,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: relationOneSlug,
|
||||
fields: baseRelationshipFields,
|
||||
},
|
||||
{
|
||||
slug: relationTwoSlug,
|
||||
fields: baseRelationshipFields,
|
||||
},
|
||||
{
|
||||
slug: relationRestrictedSlug,
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: baseRelationshipFields,
|
||||
access: {
|
||||
read: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: relationWithTitleSlug,
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: baseRelationshipFields,
|
||||
},
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
// Create docs to relate to
|
||||
const { id: relationOneDocId } = await payload.create<RelationOne>({
|
||||
collection: relationOneSlug,
|
||||
data: {
|
||||
name: relationOneSlug,
|
||||
},
|
||||
});
|
||||
|
||||
await payload.create<RelationOne>({
|
||||
collection: relationOneSlug,
|
||||
data: {
|
||||
name: relationOneSlug,
|
||||
},
|
||||
});
|
||||
|
||||
await payload.create<RelationTwo>({
|
||||
collection: relationTwoSlug,
|
||||
data: {
|
||||
name: relationTwoSlug,
|
||||
},
|
||||
});
|
||||
|
||||
// Existing relationships
|
||||
const { id: restrictedDocId } = await payload.create<RelationRestricted>({
|
||||
collection: relationRestrictedSlug,
|
||||
data: {
|
||||
name: 'relation-restricted',
|
||||
},
|
||||
});
|
||||
const { id: relationWithTitleDocId } = await payload.create<RelationWithTitle>({
|
||||
collection: relationWithTitleSlug,
|
||||
data: {
|
||||
name: 'relation-title',
|
||||
},
|
||||
});
|
||||
await payload.create<RelationOne>({
|
||||
collection: slug,
|
||||
data: {
|
||||
name: 'with-existing-relations',
|
||||
relationship: relationOneDocId,
|
||||
relationshipRestricted: restrictedDocId,
|
||||
relationshipWithTitle: relationWithTitleDocId,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
235
test/e2e/fields-relationship/index.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import payload from '../../../src';
|
||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import { firstRegister, saveDocAndAssert } from '../helpers';
|
||||
import type {
|
||||
FieldsRelationship as CollectionWithRelationships,
|
||||
RelationOne,
|
||||
RelationRestricted,
|
||||
RelationTwo,
|
||||
RelationWithTitle,
|
||||
} from './config';
|
||||
import {
|
||||
relationOneSlug,
|
||||
relationRestrictedSlug,
|
||||
relationTwoSlug,
|
||||
relationWithTitleSlug,
|
||||
slug,
|
||||
} from './config';
|
||||
|
||||
const { beforeAll, describe } = test;
|
||||
|
||||
let url: AdminUrlUtil;
|
||||
|
||||
describe('fields - relationship', () => {
|
||||
let page: Page;
|
||||
let relationOneDoc: RelationOne;
|
||||
let anotherRelationOneDoc: RelationOne;
|
||||
let relationTwoDoc: RelationTwo;
|
||||
|
||||
let docWithExistingRelations: CollectionWithRelationships;
|
||||
let restrictedRelation: RelationRestricted;
|
||||
let relationWithTitle: RelationWithTitle;
|
||||
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadTest({
|
||||
__dirname,
|
||||
init: {
|
||||
local: false,
|
||||
},
|
||||
});
|
||||
await clearAllDocs();
|
||||
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
// Create docs to relate to
|
||||
relationOneDoc = await payload.create<RelationOne>({
|
||||
collection: relationOneSlug,
|
||||
data: {
|
||||
name: 'relation',
|
||||
},
|
||||
});
|
||||
|
||||
anotherRelationOneDoc = await payload.create<RelationOne>({
|
||||
collection: relationOneSlug,
|
||||
data: {
|
||||
name: 'relation',
|
||||
},
|
||||
});
|
||||
|
||||
relationTwoDoc = await payload.create<RelationTwo>({
|
||||
collection: relationTwoSlug,
|
||||
data: {
|
||||
name: 'second-relation',
|
||||
},
|
||||
});
|
||||
|
||||
// Create restricted doc
|
||||
restrictedRelation = await payload.create<RelationRestricted>({
|
||||
collection: relationRestrictedSlug,
|
||||
data: {
|
||||
name: 'restricted',
|
||||
},
|
||||
});
|
||||
|
||||
// Doc with useAsTitle
|
||||
relationWithTitle = await payload.create<RelationWithTitle>({
|
||||
collection: relationWithTitleSlug,
|
||||
data: {
|
||||
name: 'relation-title',
|
||||
},
|
||||
});
|
||||
|
||||
// Add restricted doc as relation
|
||||
docWithExistingRelations = await payload.create<CollectionWithRelationships>({
|
||||
collection: slug,
|
||||
data: {
|
||||
name: 'with-existing-relations',
|
||||
relationship: relationOneDoc.id,
|
||||
relationshipRestricted: restrictedRelation.id,
|
||||
relationshipWithTitle: relationWithTitle.id,
|
||||
},
|
||||
});
|
||||
|
||||
await firstRegister({ page, serverURL });
|
||||
});
|
||||
|
||||
test('should create relationship', async () => {
|
||||
await page.goto(url.create);
|
||||
|
||||
const fields = page.locator('.render-fields >> .react-select');
|
||||
const relationshipField = fields.nth(0);
|
||||
|
||||
await relationshipField.click({ delay: 100 });
|
||||
|
||||
const options = page.locator('.rs__option');
|
||||
|
||||
await expect(options).toHaveCount(3); // None + two docs
|
||||
|
||||
// Select a relationship
|
||||
await options.nth(1).click();
|
||||
await expect(relationshipField).toContainText(relationOneDoc.id);
|
||||
|
||||
await saveDocAndAssert(page);
|
||||
});
|
||||
|
||||
test('should create hasMany relationship', async () => {
|
||||
await page.goto(url.create);
|
||||
|
||||
const fields = page.locator('.render-fields >> .react-select');
|
||||
const relationshipHasManyField = fields.nth(1);
|
||||
|
||||
await relationshipHasManyField.click({ delay: 100 });
|
||||
|
||||
const options = page.locator('.rs__option');
|
||||
|
||||
await expect(options).toHaveCount(2); // Two relationship options
|
||||
|
||||
// Add one relationship
|
||||
await options.locator(`text=${relationOneDoc.id}`).click();
|
||||
await expect(relationshipHasManyField).toContainText(relationOneDoc.id);
|
||||
await expect(relationshipHasManyField).not.toContainText(anotherRelationOneDoc.id);
|
||||
|
||||
// Add second relationship
|
||||
await relationshipHasManyField.click({ delay: 100 });
|
||||
await options.locator(`text=${anotherRelationOneDoc.id}`).click();
|
||||
await expect(relationshipHasManyField).toContainText(anotherRelationOneDoc.id);
|
||||
|
||||
// No options left
|
||||
await relationshipHasManyField.click({ delay: 100 });
|
||||
await expect(page.locator('.rs__menu')).toHaveText('No options');
|
||||
|
||||
await saveDocAndAssert(page);
|
||||
});
|
||||
|
||||
test('should create relations to multiple collections', async () => {
|
||||
await page.goto(url.create);
|
||||
|
||||
const fields = page.locator('.render-fields >> .react-select');
|
||||
const relationshipMultipleField = fields.nth(2);
|
||||
|
||||
await relationshipMultipleField.click({ delay: 100 });
|
||||
|
||||
const options = page.locator('.rs__option');
|
||||
|
||||
await expect(options).toHaveCount(4); // None + 3 docs
|
||||
|
||||
// Add one relationship
|
||||
await options.locator(`text=${relationOneDoc.id}`).click();
|
||||
await expect(relationshipMultipleField).toContainText(relationOneDoc.id);
|
||||
|
||||
// Add relationship of different collection
|
||||
await relationshipMultipleField.click({ delay: 100 });
|
||||
await options.locator(`text=${relationTwoDoc.id}`).click();
|
||||
await expect(relationshipMultipleField).toContainText(relationTwoDoc.id);
|
||||
|
||||
await saveDocAndAssert(page);
|
||||
});
|
||||
|
||||
describe('existing relationships', () => {
|
||||
test('should highlight existing relationship', async () => {
|
||||
await page.goto(url.doc(docWithExistingRelations.id));
|
||||
|
||||
const fields = page.locator('.render-fields >> .react-select');
|
||||
const relationOneField = fields.nth(0);
|
||||
|
||||
// Check dropdown options
|
||||
await relationOneField.click({ delay: 100 });
|
||||
|
||||
await expect(page.locator('.rs__option--is-selected')).toHaveCount(1);
|
||||
await expect(page.locator('.rs__option--is-selected')).toHaveText(relationOneDoc.id);
|
||||
});
|
||||
|
||||
test('should show untitled ID on restricted relation', async () => {
|
||||
await page.goto(url.doc(docWithExistingRelations.id));
|
||||
|
||||
const fields = page.locator('.render-fields >> .react-select');
|
||||
const restrictedRelationField = fields.nth(3);
|
||||
|
||||
// Check existing relationship has untitled ID
|
||||
await expect(restrictedRelationField).toContainText(`Untitled - ID: ${restrictedRelation.id}`);
|
||||
|
||||
// Check dropdown options
|
||||
await restrictedRelationField.click({ delay: 100 });
|
||||
const options = page.locator('.rs__option');
|
||||
|
||||
await expect(options).toHaveCount(2); // None + 1 Unitled ID
|
||||
});
|
||||
|
||||
test('should show useAsTitle on relation', async () => {
|
||||
await page.goto(url.doc(docWithExistingRelations.id));
|
||||
|
||||
const fields = page.locator('.render-fields >> .react-select');
|
||||
const relationWithTitleField = fields.nth(4);
|
||||
|
||||
// Check existing relationship for correct title
|
||||
await expect(relationWithTitleField).toHaveText(relationWithTitle.name);
|
||||
|
||||
await relationWithTitleField.click({ delay: 100 });
|
||||
const options = page.locator('.rs__option');
|
||||
|
||||
await expect(options).toHaveCount(2); // None + 1 Doc
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function clearAllDocs(): Promise<void> {
|
||||
await clearCollectionDocs(slug);
|
||||
await clearCollectionDocs(relationOneSlug);
|
||||
await clearCollectionDocs(relationTwoSlug);
|
||||
await clearCollectionDocs(relationRestrictedSlug);
|
||||
await clearCollectionDocs(relationWithTitleSlug);
|
||||
}
|
||||
|
||||
async function clearCollectionDocs(collectionSlug: string): Promise<void> {
|
||||
const ids = (await payload.find({ collection: collectionSlug, limit: 100 })).docs.map((doc) => doc.id);
|
||||
await mapAsync(ids, async (id) => {
|
||||
await payload.delete({ collection: collectionSlug, id });
|
||||
});
|
||||
}
|
||||
49
test/e2e/helpers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import wait from '../../src/utilities/wait';
|
||||
|
||||
|
||||
export const credentials = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
roles: ['admin'],
|
||||
};
|
||||
|
||||
type FirstRegisterArgs = {
|
||||
page: Page,
|
||||
serverURL: string,
|
||||
}
|
||||
|
||||
type LoginArgs = {
|
||||
page: Page,
|
||||
serverURL: string,
|
||||
}
|
||||
|
||||
export async function firstRegister(args: FirstRegisterArgs): Promise<void> {
|
||||
const { page, serverURL } = args;
|
||||
|
||||
await page.goto(`${serverURL}/admin`);
|
||||
await page.fill('#email', credentials.email);
|
||||
await page.fill('#password', credentials.password);
|
||||
await page.fill('#confirm-password', credentials.password);
|
||||
await wait(500);
|
||||
await page.click('[type=submit]');
|
||||
await page.waitForURL(`${serverURL}/admin`);
|
||||
}
|
||||
|
||||
export async function login(args: LoginArgs): Promise<void> {
|
||||
const { page, serverURL } = args;
|
||||
|
||||
await page.goto(`${serverURL}/admin`);
|
||||
await page.fill('#email', credentials.email);
|
||||
await page.fill('#password', credentials.password);
|
||||
await wait(500);
|
||||
await page.click('[type=submit]');
|
||||
await page.waitForURL(`${serverURL}/admin`);
|
||||
}
|
||||
|
||||
export async function saveDocAndAssert(page: Page): Promise<void> {
|
||||
await page.click('text=Save', { delay: 100 });
|
||||
await expect(page.locator('.Toastify')).toContainText('successfully');
|
||||
expect(page.url()).not.toContain('create');
|
||||
}
|
||||
51
test/e2e/localization/config.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export const slug = 'localized-posts';
|
||||
|
||||
export interface LocalizedPost {
|
||||
id: string
|
||||
title: string,
|
||||
description: string
|
||||
}
|
||||
|
||||
export default buildConfig({
|
||||
localization: {
|
||||
locales: [
|
||||
'en',
|
||||
'es',
|
||||
],
|
||||
defaultLocale: 'en',
|
||||
},
|
||||
collections: [{
|
||||
slug,
|
||||
access: {
|
||||
read: () => true,
|
||||
create: () => true,
|
||||
delete: () => true,
|
||||
update: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
}],
|
||||
onInit: async (payload) => {
|
||||
await mapAsync([...Array(11)], async () => {
|
||||
await payload.create<LocalizedPost>({
|
||||
collection: slug,
|
||||
data: {
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
130
test/e2e/localization/index.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import payload from '../../../src';
|
||||
import type { TypeWithTimestamps } from '../../../src/collections/config/types';
|
||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import { firstRegister, saveDocAndAssert } from '../helpers';
|
||||
import type { LocalizedPost } from './config';
|
||||
import { slug } from './config';
|
||||
|
||||
/**
|
||||
* TODO: Localization
|
||||
* - [x] create doc in spanish locale
|
||||
* - [x] retrieve doc in spanish locale
|
||||
* - [x] retrieve doc in default locale, check for null fields
|
||||
* - add translations in english to spanish doc
|
||||
* - [x] check locale toggle button
|
||||
*
|
||||
* Fieldtypes to test: (collections for each field type)
|
||||
* - localized and non-localized: array, block, group, relationship, text
|
||||
*
|
||||
* Repeat above for Globals
|
||||
*/
|
||||
|
||||
const { beforeAll, describe, afterEach } = test;
|
||||
let url: AdminUrlUtil;
|
||||
|
||||
const defaultLocale = 'en';
|
||||
const title = 'english title';
|
||||
const spanishTitle = 'spanish title';
|
||||
const description = 'description';
|
||||
|
||||
let page: Page;
|
||||
describe('Localization', () => {
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadTest({
|
||||
__dirname,
|
||||
init: {
|
||||
local: false,
|
||||
},
|
||||
});
|
||||
|
||||
await clearDocs(); // Clear any seeded data from onInit
|
||||
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
await firstRegister({ page, serverURL });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearDocs();
|
||||
});
|
||||
|
||||
describe('localized text', () => {
|
||||
test('create english post, switch to spanish', async () => {
|
||||
await page.goto(url.create);
|
||||
|
||||
await fillValues({ title, description });
|
||||
await saveDocAndAssert(page);
|
||||
|
||||
// Change back to English
|
||||
await changeLocale('es');
|
||||
|
||||
// Localized field should not be populated
|
||||
await expect(page.locator('#title')).toBeEmpty();
|
||||
await expect(page.locator('#description')).toHaveValue(description);
|
||||
|
||||
await fillValues({ title: spanishTitle, description });
|
||||
await saveDocAndAssert(page);
|
||||
await changeLocale(defaultLocale);
|
||||
|
||||
// Expect english title
|
||||
await expect(page.locator('#title')).toHaveValue(title);
|
||||
await expect(page.locator('#description')).toHaveValue(description);
|
||||
});
|
||||
|
||||
test('create spanish post, add english', async () => {
|
||||
await page.goto(url.create);
|
||||
|
||||
const newLocale = 'es';
|
||||
|
||||
// Change to Spanish
|
||||
await changeLocale(newLocale);
|
||||
|
||||
await fillValues({ title: spanishTitle, description });
|
||||
await saveDocAndAssert(page);
|
||||
|
||||
// Change back to English
|
||||
await changeLocale(defaultLocale);
|
||||
|
||||
// Localized field should not be populated
|
||||
await expect(page.locator('#title')).toBeEmpty();
|
||||
await expect(page.locator('#description')).toHaveValue(description);
|
||||
|
||||
// Add English
|
||||
|
||||
await fillValues({ title, description });
|
||||
await saveDocAndAssert(page);
|
||||
await saveDocAndAssert(page);
|
||||
|
||||
await expect(page.locator('#title')).toHaveValue(title);
|
||||
await expect(page.locator('#description')).toHaveValue(description);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function clearDocs(): Promise<void> {
|
||||
const allDocs = await payload.find<LocalizedPost>({ collection: slug, limit: 100 });
|
||||
const ids = allDocs.docs.map((doc) => doc.id);
|
||||
await mapAsync(ids, async (id) => {
|
||||
await payload.delete({ collection: slug, id });
|
||||
});
|
||||
}
|
||||
|
||||
async function fillValues(data: Partial<Omit<LocalizedPost, keyof TypeWithTimestamps>>) {
|
||||
const { title: titleVal, description: descVal } = data;
|
||||
|
||||
if (titleVal) await page.locator('#title').fill(titleVal);
|
||||
if (descVal) await page.locator('#description').fill(descVal);
|
||||
}
|
||||
|
||||
async function changeLocale(newLocale: string) {
|
||||
await page.locator('.localizer >> button').first().click();
|
||||
await page.locator(`.localizer >> a:has-text("${newLocale}")`).click();
|
||||
expect(page.url()).toContain(`locale=${newLocale}`);
|
||||
}
|
||||
10
test/e2e/versions/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export const slug = 'slugname';
|
||||
|
||||
export default buildConfig({
|
||||
collections: [{
|
||||
slug,
|
||||
fields: [],
|
||||
}],
|
||||
});
|
||||
58
test/e2e/versions/index.template.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { AdminUrlUtil } from '../../helpers/adminUrlUtil';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import { firstRegister } from '../helpers';
|
||||
import { slug } from './config';
|
||||
|
||||
|
||||
/**
|
||||
* TODO: Versions, 3 separate collections
|
||||
* - drafts
|
||||
* - save draft before publishing
|
||||
* - publish immediately
|
||||
* - validation should be skipped when creating a draft
|
||||
*
|
||||
* - autosave
|
||||
* - versions (no drafts)
|
||||
* - version control shown
|
||||
* - assert version counts increment
|
||||
* - navigate to versions
|
||||
* - versions view accurately shows number of versions
|
||||
* - compare
|
||||
* - restore version
|
||||
* - specify locales to show
|
||||
*/
|
||||
|
||||
const { beforeAll, describe } = test;
|
||||
let url: AdminUrlUtil;
|
||||
|
||||
describe('suite name', () => {
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadTest({
|
||||
__dirname,
|
||||
init: {
|
||||
local: false,
|
||||
},
|
||||
});
|
||||
// await clearDocs(); // Clear any seeded data from onInit
|
||||
|
||||
url = new AdminUrlUtil(serverURL, slug);
|
||||
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
await firstRegister({ page, serverURL });
|
||||
});
|
||||
|
||||
// afterEach(async () => {
|
||||
// });
|
||||
|
||||
describe('feature', () => {
|
||||
test('testname', () => {
|
||||
expect(1).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
17
test/helpers/adminUrlUtil.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class AdminUrlUtil {
|
||||
admin: string;
|
||||
|
||||
collection: string;
|
||||
|
||||
create: string;
|
||||
|
||||
constructor(serverURL: string, slug: string) {
|
||||
this.admin = `${serverURL}/admin`;
|
||||
this.collection = `${this.admin}/collections/${slug}`;
|
||||
this.create = `${this.collection}/create`;
|
||||
}
|
||||
|
||||
doc(id: string): string {
|
||||
return `${this.collection}/${id}`;
|
||||
}
|
||||
}
|
||||
53
test/helpers/configHelpers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import getPort from 'get-port';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import express from 'express';
|
||||
import type { CollectionConfig } from '../../src/collections/config/types';
|
||||
import type { InitOptions } from '../../src/config/types';
|
||||
import payload from '../../src';
|
||||
|
||||
type Options = {
|
||||
__dirname: string
|
||||
init?: Partial<InitOptions>
|
||||
}
|
||||
|
||||
export async function initPayloadE2E(__dirname: string): Promise<{ serverURL: string }> {
|
||||
return initPayloadTest({
|
||||
__dirname,
|
||||
init: {
|
||||
local: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function initPayloadTest(options: Options): Promise<{ serverURL: string }> {
|
||||
const initOptions = {
|
||||
local: true,
|
||||
secret: uuid(),
|
||||
mongoURL: `mongodb://localhost/${uuid()}`,
|
||||
...options.init || {},
|
||||
};
|
||||
|
||||
process.env.PAYLOAD_CONFIG_PATH = path.resolve(options.__dirname, './config.ts');
|
||||
|
||||
const port = await getPort();
|
||||
|
||||
if (!initOptions?.local) {
|
||||
initOptions.express = express();
|
||||
}
|
||||
|
||||
await payload.init(initOptions);
|
||||
|
||||
if (initOptions.express) {
|
||||
initOptions.express.listen(port);
|
||||
}
|
||||
|
||||
return { serverURL: `http://localhost:${port}` };
|
||||
}
|
||||
|
||||
export const openAccess: CollectionConfig['access'] = {
|
||||
read: () => true,
|
||||
create: () => true,
|
||||
delete: () => true,
|
||||
update: () => true,
|
||||
};
|
||||
172
test/helpers/rest.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import qs from 'qs';
|
||||
import type { Config } from '../../src/config/types';
|
||||
import type { PaginatedDocs } from '../../src/mongoose/types';
|
||||
import type { Where } from '../../src/types';
|
||||
|
||||
require('isomorphic-fetch');
|
||||
|
||||
type Args = {
|
||||
serverURL: string
|
||||
defaultSlug: string
|
||||
}
|
||||
|
||||
type LoginArgs = {
|
||||
email: string
|
||||
password: string
|
||||
collection: string
|
||||
}
|
||||
|
||||
type CreateArgs<T = any> = {
|
||||
slug?: string
|
||||
data: T
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
type FindArgs = {
|
||||
slug?: string
|
||||
query?: Where
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
type UpdateArgs<T = any> = {
|
||||
slug?: string
|
||||
id: string
|
||||
data: Partial<T>
|
||||
query?: any
|
||||
}
|
||||
|
||||
type DocResponse<T> = {
|
||||
status: number
|
||||
doc: T
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: '',
|
||||
};
|
||||
|
||||
type QueryResponse<T> = {
|
||||
status: number;
|
||||
result: PaginatedDocs<T>;
|
||||
};
|
||||
|
||||
export class RESTClient {
|
||||
private readonly config: Config;
|
||||
|
||||
private token: string;
|
||||
|
||||
private serverURL: string;
|
||||
|
||||
private defaultSlug: string;
|
||||
|
||||
constructor(config: Config, args: Args) {
|
||||
this.config = config;
|
||||
this.serverURL = args.serverURL;
|
||||
this.defaultSlug = args.defaultSlug;
|
||||
}
|
||||
|
||||
async login(incomingArgs?: LoginArgs): Promise<string> {
|
||||
const args = incomingArgs ?? {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
collection: 'users',
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.serverURL}/api/${args.collection}/login`, {
|
||||
body: JSON.stringify({
|
||||
email: args.email,
|
||||
password: args.password,
|
||||
}),
|
||||
headers,
|
||||
method: 'post',
|
||||
});
|
||||
|
||||
const { token } = await response.json();
|
||||
|
||||
this.token = token;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async create<T = any>(args: CreateArgs): Promise<DocResponse<T>> {
|
||||
const options = {
|
||||
body: JSON.stringify(args.data),
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: '',
|
||||
},
|
||||
method: 'post',
|
||||
};
|
||||
|
||||
if (args.auth) {
|
||||
options.headers.Authorization = `JWT ${this.token}`;
|
||||
}
|
||||
|
||||
const slug = args.slug || this.defaultSlug;
|
||||
const response = await fetch(`${this.serverURL}/api/${slug}`, options);
|
||||
const { status } = response;
|
||||
const { doc } = await response.json();
|
||||
return { status, doc };
|
||||
}
|
||||
|
||||
async find<T = any>(args?: FindArgs): Promise<QueryResponse<T>> {
|
||||
const options = {
|
||||
headers: {
|
||||
...headers,
|
||||
Authorization: '',
|
||||
},
|
||||
};
|
||||
|
||||
if (args?.auth) {
|
||||
options.headers.Authorization = `JWT ${this.token}`;
|
||||
}
|
||||
|
||||
const slug = args?.slug || this.defaultSlug;
|
||||
const whereQuery = qs.stringify(args?.query ? { where: args.query } : {}, {
|
||||
addQueryPrefix: true,
|
||||
});
|
||||
const fetchURL = `${this.serverURL}/api/${slug}${whereQuery}`;
|
||||
const response = await fetch(fetchURL, options);
|
||||
const { status } = response;
|
||||
const result = await response.json();
|
||||
if (result.errors) throw new Error(result.errors[0].message);
|
||||
return { status, result };
|
||||
}
|
||||
|
||||
async update<T = any>(args: UpdateArgs<T>): Promise<DocResponse<T>> {
|
||||
const { slug, id, data, query } = args;
|
||||
const formattedQs = qs.stringify(query);
|
||||
const response = await fetch(
|
||||
`${this.serverURL}/api/${slug || this.defaultSlug}/${id}${formattedQs}`,
|
||||
{
|
||||
body: JSON.stringify(data),
|
||||
headers,
|
||||
method: 'put',
|
||||
},
|
||||
);
|
||||
const { status } = response;
|
||||
const json = await response.json();
|
||||
return { status, doc: json.doc };
|
||||
}
|
||||
|
||||
async findByID<T = any>(id: string, args?: { slug?: string }): Promise<DocResponse<T>> {
|
||||
const response = await fetch(`${this.serverURL}/api/${args?.slug || this.defaultSlug}/${id}`, {
|
||||
headers,
|
||||
method: 'get',
|
||||
});
|
||||
const { status } = response;
|
||||
const doc = await response.json();
|
||||
return { status, doc };
|
||||
}
|
||||
|
||||
async delete<T = any>(id: string, args?: { slug?: string }): Promise<DocResponse<T>> {
|
||||
const response = await fetch(`${this.serverURL}/api/${args?.slug || this.defaultSlug}/${id}`, {
|
||||
headers,
|
||||
method: 'delete',
|
||||
});
|
||||
const { status } = response;
|
||||
const doc = await response.json();
|
||||
return { status, doc };
|
||||
}
|
||||
}
|
||||
24
test/int/array-update/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export default buildConfig({
|
||||
collections: [{
|
||||
slug: 'arrays',
|
||||
fields: [
|
||||
{
|
||||
name: 'array',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'required',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'optional',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
108
test/int/array-update/index.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import payload from '../../../src';
|
||||
import config from './config';
|
||||
|
||||
const collection = config.collections[0]?.slug;
|
||||
|
||||
describe('array-update', () => {
|
||||
beforeAll(async () => {
|
||||
await initPayloadTest({ __dirname });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
await mongoose.connection.close();
|
||||
await payload.mongoMemoryServer.stop();
|
||||
});
|
||||
|
||||
it('should persist existing array-based data while updating and passing row ID', async () => {
|
||||
const originalText = 'some optional text';
|
||||
|
||||
const doc = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
array: [
|
||||
{
|
||||
required: 'a required field here',
|
||||
optional: originalText,
|
||||
},
|
||||
{
|
||||
required: 'another required field here',
|
||||
optional: 'this is cool',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const arrayWithExistingValues = [
|
||||
...doc.array,
|
||||
];
|
||||
|
||||
const updatedText = 'this is some new text for the first item in array';
|
||||
|
||||
arrayWithExistingValues[0] = {
|
||||
id: arrayWithExistingValues[0].id,
|
||||
required: updatedText,
|
||||
};
|
||||
|
||||
|
||||
const updatedDoc = await payload.update({
|
||||
id: doc.id,
|
||||
collection,
|
||||
data: {
|
||||
array: arrayWithExistingValues,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedDoc.array[0]).toMatchObject({
|
||||
required: updatedText,
|
||||
optional: originalText,
|
||||
});
|
||||
});
|
||||
|
||||
it('should disregard existing array-based data while updating and NOT passing row ID', async () => {
|
||||
const updatedText = 'here is some new text';
|
||||
|
||||
const secondArrayItem = {
|
||||
required: 'test',
|
||||
optional: 'optional test',
|
||||
};
|
||||
|
||||
const doc = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
array: [
|
||||
{
|
||||
required: 'a required field here',
|
||||
optional: 'some optional text',
|
||||
},
|
||||
secondArrayItem,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const updatedDoc = await payload.update<any>({
|
||||
id: doc.id,
|
||||
collection,
|
||||
data: {
|
||||
array: [
|
||||
{
|
||||
required: updatedText,
|
||||
},
|
||||
{
|
||||
id: doc.array[1].id,
|
||||
required: doc.array[1].required,
|
||||
// NOTE - not passing optional field. It should persist
|
||||
// because we're passing ID
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedDoc.array[0].required).toStrictEqual(updatedText);
|
||||
expect(updatedDoc.array[0].optional).toBeUndefined();
|
||||
|
||||
expect(updatedDoc.array[1]).toMatchObject(secondArrayItem);
|
||||
});
|
||||
});
|
||||
15
test/int/buildConfig.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import merge from 'deepmerge';
|
||||
import { buildConfig as buildPayloadConfig } from '../../src/config/build';
|
||||
import type { Config, SanitizedConfig } from '../../src/config/types';
|
||||
|
||||
export function buildConfig(overrides?: Partial<Config>): SanitizedConfig {
|
||||
const baseConfig: Config = {};
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
baseConfig.admin = {
|
||||
disable: true,
|
||||
};
|
||||
}
|
||||
|
||||
return buildPayloadConfig(merge(baseConfig, overrides || {}));
|
||||
}
|
||||
15
test/int/collections-graphql/config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { openAccess } from '../../helpers/configHelpers';
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export default buildConfig({
|
||||
collections: [{
|
||||
slug: 'posts',
|
||||
access: openAccess,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
43
test/int/collections-graphql/index.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import config from './config';
|
||||
import payload from '../../../src';
|
||||
import { RESTClient } from '../../helpers/rest';
|
||||
|
||||
const collection = config.collections[0]?.slug;
|
||||
|
||||
let client: RESTClient;
|
||||
|
||||
describe('collections-graphql', () => {
|
||||
beforeAll(async () => {
|
||||
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
|
||||
client = new RESTClient(config, { serverURL });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
await mongoose.connection.close();
|
||||
await payload.mongoMemoryServer.stop();
|
||||
});
|
||||
|
||||
it('should create', async () => {
|
||||
const title = 'hello';
|
||||
|
||||
const { doc } = await client.create({
|
||||
slug: collection,
|
||||
data: {
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
expect(doc.title).toStrictEqual(title);
|
||||
});
|
||||
|
||||
it('should find', async () => {
|
||||
const { result } = await client.find({
|
||||
slug: collection,
|
||||
});
|
||||
|
||||
expect(result.totalDocs).toStrictEqual(1);
|
||||
});
|
||||
});
|
||||
105
test/int/collections-rest/config.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { CollectionConfig } from '../../../src/collections/config/types';
|
||||
import { buildConfig } from '../buildConfig';
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
number?: number;
|
||||
relationField?: Relation | string
|
||||
relationHasManyField?: RelationHasMany[] | string[]
|
||||
relationMultiRelationTo?: Relation[] | string[]
|
||||
}
|
||||
|
||||
export interface Relation {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type RelationHasMany = Relation
|
||||
|
||||
const openAccess = {
|
||||
create: () => true,
|
||||
read: () => true,
|
||||
update: () => true,
|
||||
delete: () => true,
|
||||
};
|
||||
|
||||
const collectionWithName = (slug: string): CollectionConfig => {
|
||||
return {
|
||||
slug,
|
||||
access: openAccess,
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const slug = 'posts';
|
||||
export const relationSlug = 'relation-normal';
|
||||
export const relationHasManySlug = 'relation-has-many';
|
||||
export const relationMultipleRelationToSlug = 'relation-multi-relation-to';
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug,
|
||||
access: openAccess,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'number',
|
||||
type: 'number',
|
||||
},
|
||||
// Relationship
|
||||
{
|
||||
name: 'relationField',
|
||||
type: 'relationship',
|
||||
relationTo: relationSlug,
|
||||
},
|
||||
// Relation hasMany
|
||||
{
|
||||
name: 'relationHasManyField',
|
||||
type: 'relationship',
|
||||
relationTo: relationHasManySlug,
|
||||
hasMany: true,
|
||||
},
|
||||
// Relation multiple relationTo
|
||||
{
|
||||
name: 'relationMultiRelationTo',
|
||||
type: 'relationship',
|
||||
relationTo: [relationSlug, 'dummy'],
|
||||
},
|
||||
],
|
||||
},
|
||||
collectionWithName(relationSlug),
|
||||
collectionWithName(relationHasManySlug),
|
||||
collectionWithName('dummy'),
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
const rel1 = await payload.create<RelationHasMany>({
|
||||
collection: relationHasManySlug,
|
||||
data: {
|
||||
name: 'name',
|
||||
},
|
||||
});
|
||||
|
||||
await payload.create({
|
||||
collection: slug,
|
||||
data: {
|
||||
title: 'title',
|
||||
relationHasManyField: rel1.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
433
test/int/collections-rest/index.spec.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { initPayloadTest } from '../../helpers/configHelpers';
|
||||
import type { Relation, Post, RelationHasMany } from './config';
|
||||
import config, { relationHasManySlug, slug, relationSlug } from './config';
|
||||
import payload from '../../../src';
|
||||
import { RESTClient } from '../../helpers/rest';
|
||||
import { mapAsync } from '../../../src/utilities/mapAsync';
|
||||
|
||||
let client: RESTClient;
|
||||
|
||||
describe('collections-rest', () => {
|
||||
beforeAll(async () => {
|
||||
const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } });
|
||||
client = new RESTClient(config, { serverURL, defaultSlug: slug });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
await mongoose.connection.close();
|
||||
await payload.mongoMemoryServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearDocs();
|
||||
});
|
||||
|
||||
describe('CRUD', () => {
|
||||
it('should create', async () => {
|
||||
const data = {
|
||||
title: 'title',
|
||||
};
|
||||
const doc = await createPost(data);
|
||||
|
||||
expect(doc).toMatchObject(data);
|
||||
});
|
||||
|
||||
it('should find', async () => {
|
||||
const post1 = await createPost();
|
||||
const post2 = await createPost();
|
||||
const { status, result } = await client.find<Post>();
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(2);
|
||||
const expectedDocs = [post1, post2];
|
||||
expect(result.docs).toHaveLength(expectedDocs.length);
|
||||
expect(result.docs).toEqual(expect.arrayContaining(expectedDocs));
|
||||
});
|
||||
|
||||
it('should update existing', async () => {
|
||||
const { id, description } = await createPost({ description: 'desc' });
|
||||
const updatedTitle = 'updated-title';
|
||||
|
||||
const { status, doc: updated } = await client.update<Post>({
|
||||
id,
|
||||
data: { title: updatedTitle },
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(updated.title).toEqual(updatedTitle);
|
||||
expect(updated.description).toEqual(description); // Check was not modified
|
||||
});
|
||||
|
||||
it('should delete', async () => {
|
||||
const { id } = await createPost();
|
||||
|
||||
const { status, doc } = await client.delete<Post>(id);
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(doc.id).toEqual(id);
|
||||
});
|
||||
|
||||
it('should include metadata', async () => {
|
||||
await createPosts(11);
|
||||
|
||||
const { result } = await client.find<Post>();
|
||||
|
||||
expect(result.totalDocs).toBeGreaterThan(0);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.pagingCounter).toBe(1);
|
||||
expect(result.hasPrevPage).toBe(false);
|
||||
expect(result.hasNextPage).toBe(true);
|
||||
expect(result.prevPage).toBeNull();
|
||||
expect(result.nextPage).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Querying', () => {
|
||||
describe('Relationships', () => {
|
||||
it('should query nested relationship', async () => {
|
||||
const nameToQuery = 'name';
|
||||
const { doc: relation } = await client.create<Relation>({
|
||||
slug: relationSlug,
|
||||
data: {
|
||||
name: nameToQuery,
|
||||
},
|
||||
});
|
||||
|
||||
const post1 = await createPost({
|
||||
relationField: relation.id,
|
||||
});
|
||||
await createPost();
|
||||
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
'relationField.name': {
|
||||
equals: nameToQuery,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.docs).toEqual([post1]);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
});
|
||||
|
||||
it('should query nested relationship - hasMany', async () => {
|
||||
const nameToQuery = 'name';
|
||||
const { doc: relation } = await client.create<RelationHasMany>({
|
||||
slug: relationHasManySlug,
|
||||
data: {
|
||||
name: nameToQuery,
|
||||
},
|
||||
});
|
||||
|
||||
const post1 = await createPost({
|
||||
relationHasManyField: [relation.id],
|
||||
});
|
||||
await createPost();
|
||||
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
'relationHasManyField.name': {
|
||||
equals: nameToQuery,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.docs).toEqual([post1]);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Operators', () => {
|
||||
it('equals', async () => {
|
||||
const valueToQuery = 'valueToQuery';
|
||||
const post1 = await createPost({ title: valueToQuery });
|
||||
await createPost();
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
title: {
|
||||
equals: valueToQuery,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
expect(result.docs).toEqual([post1]);
|
||||
});
|
||||
|
||||
it('not_equals', async () => {
|
||||
const post1 = await createPost({ title: 'not-equals' });
|
||||
const post2 = await createPost();
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
title: {
|
||||
not_equals: post1.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
expect(result.docs).toEqual([post2]);
|
||||
});
|
||||
|
||||
it('in', async () => {
|
||||
const post1 = await createPost({ title: 'my-title' });
|
||||
await createPost();
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
title: {
|
||||
in: [post1.title],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.docs).toEqual([post1]);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
});
|
||||
|
||||
it('not_in', async () => {
|
||||
const post1 = await createPost({ title: 'not-me' });
|
||||
const post2 = await createPost();
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
title: {
|
||||
not_in: [post1.title],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.docs).toEqual([post2]);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
});
|
||||
|
||||
it('like', async () => {
|
||||
const post1 = await createPost({ title: 'prefix-value' });
|
||||
await createPost();
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
title: {
|
||||
like: post1.title.substring(0, 6),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.docs).toEqual([post1]);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
});
|
||||
|
||||
it('exists - true', async () => {
|
||||
const postWithDesc = await createPost({ description: 'exists' });
|
||||
await createPost({ description: undefined });
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
description: {
|
||||
exists: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
expect(result.docs).toEqual([postWithDesc]);
|
||||
});
|
||||
|
||||
it('exists - false', async () => {
|
||||
const postWithoutDesc = await createPost({ description: undefined });
|
||||
await createPost({ description: 'exists' });
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
description: {
|
||||
exists: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
expect(result.docs).toEqual([postWithoutDesc]);
|
||||
});
|
||||
|
||||
describe('numbers', () => {
|
||||
let post1: Post;
|
||||
let post2: Post;
|
||||
beforeEach(async () => {
|
||||
post1 = await createPost({ number: 1 });
|
||||
post2 = await createPost({ number: 2 });
|
||||
});
|
||||
|
||||
it('greater_than', async () => {
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
number: {
|
||||
greater_than: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
expect(result.docs).toEqual([post2]);
|
||||
});
|
||||
|
||||
it('greater_than_equal', async () => {
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
number: {
|
||||
greater_than_equal: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(2);
|
||||
const expectedDocs = [post1, post2];
|
||||
expect(result.docs).toHaveLength(expectedDocs.length);
|
||||
expect(result.docs).toEqual(expect.arrayContaining(expectedDocs));
|
||||
});
|
||||
|
||||
it('less_than', async () => {
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
number: {
|
||||
less_than: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
expect(result.docs).toEqual([post1]);
|
||||
});
|
||||
|
||||
it('less_than_equal', async () => {
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
number: {
|
||||
less_than_equal: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(2);
|
||||
const expectedDocs = [post1, post2];
|
||||
expect(result.docs).toHaveLength(expectedDocs.length);
|
||||
expect(result.docs).toEqual(expect.arrayContaining(expectedDocs));
|
||||
});
|
||||
});
|
||||
|
||||
it('or', async () => {
|
||||
const post1 = await createPost({ title: 'post1' });
|
||||
const post2 = await createPost({ title: 'post2' });
|
||||
await createPost();
|
||||
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
or: [
|
||||
{
|
||||
title: {
|
||||
equals: post1.title,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: {
|
||||
equals: post2.title,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
const expectedDocs = [post1, post2];
|
||||
expect(result.totalDocs).toEqual(expectedDocs.length);
|
||||
expect(result.docs).toEqual(expect.arrayContaining(expectedDocs));
|
||||
});
|
||||
|
||||
it('or - 1 result', async () => {
|
||||
const post1 = await createPost({ title: 'post1' });
|
||||
await createPost();
|
||||
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
or: [
|
||||
{
|
||||
title: {
|
||||
equals: post1.title,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: {
|
||||
equals: 'non-existent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
const expectedDocs = [post1];
|
||||
expect(result.totalDocs).toEqual(expectedDocs.length);
|
||||
expect(result.docs).toEqual(expect.arrayContaining(expectedDocs));
|
||||
});
|
||||
|
||||
it('and', async () => {
|
||||
const description = 'description';
|
||||
const post1 = await createPost({ title: 'post1', description });
|
||||
await createPost({ title: 'post2', description }); // Diff title, same desc
|
||||
await createPost();
|
||||
|
||||
const { status, result } = await client.find<Post>({
|
||||
query: {
|
||||
and: [
|
||||
{
|
||||
title: {
|
||||
equals: post1.title,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: {
|
||||
equals: description,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toEqual(200);
|
||||
expect(result.totalDocs).toEqual(1);
|
||||
expect(result.docs).toEqual([post1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
async function createPost(overrides?: Partial<Post>) {
|
||||
const { doc } = await client.create<Post>({ data: { title: 'title', ...overrides } });
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function createPosts(count: number) {
|
||||
await mapAsync([...Array(count)], async () => {
|
||||
await createPost();
|
||||
});
|
||||
}
|
||||
|
||||
async function clearDocs(): Promise<void> {
|
||||
const allDocs = await payload.find<Post>({ collection: slug, limit: 100 });
|
||||
const ids = allDocs.docs.map((doc) => doc.id);
|
||||
await mapAsync(ids, async (id) => {
|
||||
await payload.delete({ collection: slug, id });
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |