feat: testing refactor (e2e/int) (#748)

Co-authored-by: James <james@trbl.design>
Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Elliot DeNolf
2022-07-13 11:00:10 -07:00
committed by GitHub
parent b9f9f15d77
commit 90ba15f9bd
65 changed files with 3511 additions and 1965 deletions

View File

@@ -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',

View File

@@ -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
View File

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

View File

@@ -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
View File

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

View File

@@ -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"
}

View File

@@ -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`);
});
});

View File

@@ -1 +0,0 @@
export const adminURL = 'http://localhost:3000/admin';

View File

@@ -1,5 +0,0 @@
export const credentials = {
email: 'test@test.com',
password: 'test123',
roles: ['admin'],
};

View File

@@ -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');
});
});
});
});

View File

@@ -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);
});
});
});
});

View File

@@ -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',
});
});
};

View File

@@ -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);
});
});

View File

@@ -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',
});

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"es5",
"dom"
],
"types": [
"cypress"
]
},
"include": [
"**/*.ts"
]
}

View File

@@ -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',

View File

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

View File

@@ -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"
}

View File

@@ -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
View 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;

View File

@@ -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', () => {

View File

@@ -136,5 +136,6 @@ export default joi.object({
plugins: joi.array().items(
joi.func(),
),
onInit: joi.func(),
debug: joi.boolean(),
});

View File

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

View File

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

View File

@@ -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);
}

View File

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

View 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,
};

View 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
View 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
View 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();

View 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',
},
});
},
});

View 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
View 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: [],
},
],
});

View 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
View 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 || {}));
}

View 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',
},
});
});
},
});

View 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 });
});
}

View 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',
},
],
},
],
},
],
});

View 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');
});
});

View 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,
},
});
},
});

View 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
View 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');
}

View 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',
},
});
});
},
});

View 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}`);
}

View File

@@ -0,0 +1,10 @@
import { buildConfig } from '../buildConfig';
export const slug = 'slugname';
export default buildConfig({
collections: [{
slug,
fields: [],
}],
});

View 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);
});
});
});

View 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}`;
}
}

View 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
View 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 };
}
}

View 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',
},
],
},
],
}],
});

View 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
View 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 || {}));
}

View 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',
},
],
}],
});

View 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);
});
});

View 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,
},
});
},
});

View 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 });
});
}

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

2463
yarn.lock

File diff suppressed because it is too large Load Diff