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

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

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