Browse Source

refactor/Refactored all of the unit test to make all tests async, improved some factory, now test_meta database is dropped and created before running each tests, added unit tests for view rows and fixed Noco import order

pull/3358/head
Muhammed Mustafa 2 years ago
parent
commit
d01e0d28de
  1. 4
      packages/nocodb/package.json
  2. 36
      packages/nocodb/tests/unit/rest/index.test.ts
  3. 7
      packages/nocodb/tests/unit/rest/init/index.ts
  4. 149
      packages/nocodb/tests/unit/rest/tests/auth.test.ts
  5. 31
      packages/nocodb/tests/unit/rest/tests/factory/column.ts
  6. 6
      packages/nocodb/tests/unit/rest/tests/factory/project.ts
  7. 3
      packages/nocodb/tests/unit/rest/tests/factory/row.ts
  8. 18
      packages/nocodb/tests/unit/rest/tests/factory/table.ts
  9. 163
      packages/nocodb/tests/unit/rest/tests/project.test.ts
  10. 318
      packages/nocodb/tests/unit/rest/tests/table.test.ts
  11. 18
      packages/nocodb/tests/unit/rest/tests/tableRow.test.ts
  12. 537
      packages/nocodb/tests/unit/rest/tests/viewRow.test.ts

4
packages/nocodb/package.json

@ -21,8 +21,8 @@
"local:test:graphql": "cross-env DATABASE_URL=mysql://root:password@localhost:3306/sakila TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit",
"test:graphql": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/graphql.test.ts --recursive --timeout 10000 --exit",
"test:grpc": "cross-env TS_NODE_PROJECT=tsconfig.json mocha -r ts-node/register src/__tests__/grpc.test.ts --recursive --timeout 10000 --exit",
"local:test:rest": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/rest/index.test.ts --recursive --timeout 50000 --exit",
"test:rest": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/rest/index.test.ts --recursive --timeout 50000 --exit",
"local:test:rest": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/rest/index.test.ts --recursive --timeout 300000 --exit --delay",
"test:rest": "cross-env TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/rest/index.test.ts --recursive --timeout 300000 --exit --delay",
"test1": "run-s build test:*",
"test:lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different",
"test:unit": "nyc --silent ava",

36
packages/nocodb/tests/unit/rest/index.test.ts

@ -4,13 +4,39 @@ import projectTests from './tests/project.test';
import tableTests from './tests/table.test';
import tableRowTests from './tests/tableRow.test';
import viewRowTests from './tests/viewRow.test';
import knex from 'knex';
import { dbName } from './dbConfig';
process.env.NODE_ENV = 'test';
process.env.TEST = 'test';
process.env.NC_DISABLE_CACHE = 'true';
authTests();
projectTests();
tableTests();
tableRowTests();
viewRowTests();
const setupTestMetaDb = async () => {
const knexClient = knex({
client: 'mysql2',
connection: {
host: 'localhost',
port: 3306,
user: 'root',
password: 'password',
},
});
try {
await knexClient.raw(`DROP DATABASE ${dbName}`);
} catch (e) {}
await knexClient.raw(`CREATE DATABASE ${dbName}`);
}
(async function() {
await setupTestMetaDb();
authTests();
projectTests();
tableTests();
tableRowTests();
viewRowTests();
run();
})();

7
packages/nocodb/tests/unit/rest/init/index.ts

@ -1,11 +1,12 @@
import { dbConfig, dbName, sakilaDbName } from '../dbConfig';
import express from 'express';
import knex from 'knex';
import { Noco } from '../../../../src/lib';
import { dbConfig, dbName, sakilaDbName } from '../dbConfig';
import cleanupMeta from './cleanupMeta';
import {cleanUpSakila, resetAndSeedSakila} from './cleanupSakila';
import { createUser } from '../tests/factory/user';
import knex from 'knex';
import Noco from '../../../../src/lib';
let server;
const knexClient = knex(dbConfig);

149
packages/nocodb/tests/unit/rest/tests/auth.test.ts

@ -11,178 +11,151 @@ function authTests() {
context = await init();
});
it('Signup with valid email', function (done) {
request(context.app)
it('Signup with valid email', async () => {
const response = await request(context.app)
.post('/api/v1/auth/user/signup')
.send({ email: 'new@example.com', password: defaultUserArgs.password })
.expect(200, (err, res) => {
if (err) {
expect(res.status).to.equal(400);
} else {
const token = res.body.token;
expect(token).to.be.a('string');
}
done();
});
.expect(200)
const token = response.body.token;
expect(token).to.be.a('string');
});
it('Signup with invalid email', (done) => {
request(context.app)
it('Signup with invalid email', async () => {
await request(context.app)
.post('/api/v1/auth/user/signup')
.send({ email: 'test', password: defaultUserArgs.password })
.expect(400, done);
.expect(400);
});
it('Signup with invalid passsword', (done) => {
request(context.app)
it('Signup with invalid passsword', async () => {
await request(context.app)
.post('/api/v1/auth/user/signup')
.send({ email: defaultUserArgs.email, password: 'weakpass' })
.expect(400, done);
.expect(400);
});
it('Signin with valid credentials', function (done) {
request(context.app)
it('Signin with valid credentials', async () => {
const response = await request(context.app)
.post('/api/v1/auth/user/signin')
.send({
email: defaultUserArgs.email,
password: defaultUserArgs.password,
})
.expect(200, async function (err, res) {
if (err) {
console.log(res.error);
return done(err);
}
const token = res.body.token;
expect(token).to.be.a('string');
// todo: Verify token
done();
});
.expect(200);
const token = response.body.token;
expect(token).to.be.a('string');
});
it('Signup without email and password', (done) => {
request(context.app)
it('Signup without email and password', async () => {
await request(context.app)
.post('/api/v1/auth/user/signin')
// pass empty data in request
// pass empty data in await request
.send({})
.expect(400, done);
.expect(400);
});
it('Signin with invalid credentials', function (done) {
request(context.app)
it('Signin with invalid credentials', async () => {
await request(context.app)
.post('/api/v1/auth/user/signin')
.send({ email: 'abc@abc.com', password: defaultUserArgs.password })
.expect(400, done);
.expect(400);
});
it('Signin with invalid password', function (done) {
request(context.app)
it('Signin with invalid password', async () => {
await request(context.app)
.post('/api/v1/auth/user/signin')
.send({ email: defaultUserArgs.email, password: 'wrongPassword' })
.expect(400, done);
.expect(400);
});
it('me without token', function (done) {
request(context.app)
it('me without token', async () => {
const response = await request(context.app)
.get('/api/v1/auth/user/me')
.unset('xc-auth')
.expect(200, (err, res) => {
if (err) {
console.log(err, res);
done(err);
return;
}
if (!res.body?.roles?.guest) {
done('User should be guest');
return;
}
done();
});
.expect(200);
if (!response.body?.roles?.guest) {
return new Error('User should be guest');
}
});
it('me with token', function (done) {
request(context.app)
it('me with token', async () => {
const response = await request(context.app)
.get('/api/v1/auth/user/me')
.set('xc-auth', context.token)
.expect(200, function (err, res) {
if (err) {
return done(err);
}
const email = res.body.email;
expect(email).to.equal(defaultUserArgs.email);
done();
});
.expect(200);
const email = response.body.email;
expect(email).to.equal(defaultUserArgs.email);
});
it('Forgot password with a non-existing email id', function (done) {
request(context.app)
it('Forgot password with a non-existing email id', async () => {
await request(context.app)
.post('/api/v1/auth/password/forgot')
.send({ email: 'nonexisting@email.com' })
.expect(400, done);
.expect(400);
});
// todo: fix mailer issues
// it('Forgot password with an existing email id', function () {});
it('Change password', function (done) {
request(context.app)
it('Change password', async () => {
await request(context.app)
.post('/api/v1/auth/password/change')
.set('xc-auth', context.token)
.send({
currentPassword: defaultUserArgs.password,
newPassword: 'NEW' + defaultUserArgs.password,
})
.expect(200, done);
.expect(200);
});
it('Change password - after logout', function (done) {
request(context.app)
it('Change password - after logout', async () => {
await request(context.app)
.post('/api/v1/auth/password/change')
.unset('xc-auth')
.send({
currentPassword: defaultUserArgs.password,
newPassword: 'NEW' + defaultUserArgs.password,
})
.expect(500, function (_err, _res) {
done();
});
.expect(401);
});
// todo:
it('Reset Password with an invalid token', function (done) {
request(context.app)
it('Reset Password with an invalid token', async () => {
await request(context.app)
.post('/api/v1/auth/password/reset/someRandomValue')
.send({ email: defaultUserArgs.email })
.expect(400, done);
.expect(400);
});
it('Email validate with an invalid token', function (done) {
request(context.app)
it('Email validate with an invalid token', async () => {
await request(context.app)
.post('/api/v1/auth/email/validate/someRandomValue')
.send({ email: defaultUserArgs.email })
.expect(400, done);
.expect(400);
});
// todo:
// it('Email validate with a valid token', function (done) {
// // request(context.app)
// it('Email validate with a valid token', async () => {
// // await request(context.app)
// // .post('/auth/email/validate/someRandomValue')
// // .send({email: EMAIL_ID})
// // .expect(500, done);
// });
// todo:
// it('Forgot password validate with a valid token', function (done) {
// // request(context.app)
// it('Forgot password validate with a valid token', async () => {
// // await request(context.app)
// // .post('/auth/token/validate/someRandomValue')
// // .send({email: EMAIL_ID})
// // .expect(500, done);
// });
// todo:
// it('Reset Password with an valid token', function (done) {
// // request(context.app)
// it('Reset Password with an valid token', async () => {
// // await request(context.app)
// // .post('/auth/password/reset/someRandomValue')
// // .send({password: 'anewpassword'})
// // .expect(500, done);

31
packages/nocodb/tests/unit/rest/tests/factory/column.ts

@ -1,7 +1,12 @@
import { UITypes } from 'nocodb-sdk';
import request from 'supertest';
import Column from '../../../../../src/lib/models/Column';
import FormViewColumn from '../../../../../src/lib/models/FormViewColumn';
import GalleryViewColumn from '../../../../../src/lib/models/GalleryViewColumn';
import GridViewColumn from '../../../../../src/lib/models/GridViewColumn';
import Model from '../../../../../src/lib/models/Model';
import Project from '../../../../../src/lib/models/Project';
import View from '../../../../../src/lib/models/View';
const defaultColumns = [
{
@ -122,7 +127,7 @@ const createRollupColumn = async (
relatedTableName,
relatedTableColumnTitle,
}: {
project: any;
project: Project;
title: string;
rollupFunction: string;
table: Model;
@ -130,9 +135,10 @@ const createRollupColumn = async (
relatedTableColumnTitle: string;
}
) => {
const childBases = await project.getBases();
const childTable = await Model.getByIdOrName({
project_id: project.id,
base_id: project.bases[0].id,
base_id: childBases[0].id!,
table_name: relatedTableName,
});
const childTableColumns = await childTable.getColumns();
@ -168,16 +174,17 @@ const createLookupColumn = async (
relatedTableName,
relatedTableColumnTitle,
}: {
project: any;
project: Project;
title: string;
table: Model;
relatedTableName: string;
relatedTableColumnTitle: string;
}
) => {
const childBases = await project.getBases();
const childTable = await Model.getByIdOrName({
project_id: project.id,
base_id: project.bases[0].id,
base_id: childBases[0].id!,
table_name: relatedTableName,
});
const childTableColumns = await childTable.getColumns();
@ -234,10 +241,26 @@ const createLtarColumn = async (
return ltarColumn;
};
const updateViewColumn = async (context, {view, column, attr}: {column: Column, view: View, attr: any}) => {
const res = await request(context.app)
.patch(`/api/v1/db/meta/views/${view.id}/columns/${column.id}`)
.set('xc-auth', context.token)
.send({
...attr,
});
const updatedColumn: FormViewColumn | GridViewColumn | GalleryViewColumn = (await view.getColumns()).find(
(column) => column.id === column.id
)!;
return updatedColumn;
}
export {
defaultColumns,
createColumn,
createRollupColumn,
createLookupColumn,
createLtarColumn,
updateViewColumn
};

6
packages/nocodb/tests/unit/rest/tests/factory/project.ts

@ -1,4 +1,5 @@
import request from 'supertest';
import Project from '../../../../../src/lib/models/Project';
import { sakilaDbName } from '../../dbConfig';
const externalProjectConfig = {
@ -48,7 +49,8 @@ const createSakilaProject = async (context) => {
.set('xc-auth', context.token)
.send(externalProjectConfig);
const project = response.body;
const project: Project = await Project.getByTitleOrId(response.body.id);
return project;
};
@ -58,7 +60,7 @@ const createProject = async (context, projectArgs = defaultProjectValue) => {
.set('xc-auth', context.token)
.send(projectArgs);
const project = response.body;
const project: Project = await Project.getByTitleOrId(response.body.id);
return project;
};

3
packages/nocodb/tests/unit/rest/tests/factory/row.ts

@ -46,9 +46,10 @@ const listRow = async ({
sortArr?: Sort[];
};
}) => {
const bases = await project.getBases();
const baseModel = await Model.getBaseModelSQL({
id: table.id,
dbDriver: NcConnectionMgrv2.get(project.bases[0]),
dbDriver: NcConnectionMgrv2.get(bases[0]!),
});
const ignorePagination = !options;

18
packages/nocodb/tests/unit/rest/tests/factory/table.ts

@ -1,5 +1,6 @@
import request from 'supertest';
import Model from '../../../../../src/lib/models/Model';
import Project from '../../../../../src/lib/models/Project';
import { defaultColumns } from './column';
const defaultTableValue = {
@ -18,12 +19,23 @@ const createTable = async (context, project, args = {}) => {
return table;
};
const getTable = async ({project, name}: {project, name: string}) => {
const getTable = async ({project, name}: {project: Project, name: string}) => {
const bases = await project.getBases();
return await Model.getByIdOrName({
project_id: project.id,
base_id: project.bases[0].id,
base_id: bases[0].id!,
table_name: name,
});
}
export { createTable, getTable };
const getAllTables = async ({project}: {project: Project}) => {
const bases = await project.getBases();
const tables = await Model.list({
project_id: project.id,
base_id: bases[0].id!,
});
return tables;
}
export { createTable, getTable, getAllTables };

163
packages/nocodb/tests/unit/rest/tests/project.test.ts

@ -1,9 +1,9 @@
import 'mocha';
import request from 'supertest';
import init from '../init/index';
import { createProject, createSharedBase } from './factory/project';
import { beforeEach } from 'mocha';
import { Exception } from 'handlebars';
import init from '../init/index';
import Project from '../../../../src/lib/models/Project';
function projectTest() {
@ -16,72 +16,64 @@ function projectTest() {
project = await createProject(context);
});
it('Get project info', function (done) {
request(context.app)
it('Get project info', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/info`)
.set('xc-auth', context.token)
.send({})
.expect(200, done);
.expect(200);
});
// todo: Test by creating models under project and check if the UCL is working
it('UI ACL', (done) => {
request(context.app)
it('UI ACL', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/visibility-rules`)
.set('xc-auth', context.token)
.send({})
.expect(200, done);
.expect(200);
});
// todo: Test creating visibility set
it('List projects', function (done) {
request(context.app)
it('List projects', async () => {
const response = await request(context.app)
.get('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send({})
.expect(200, (err, res) => {
if (err) done(err);
else if (res.body.list.length !== 1) done('Should list only 1 project');
else if (!res.body.pageInfo) done('Should have pagination info');
else {
done();
}
});
.expect(200);
if (response.body.list.length !== 1) new Error('Should list only 1 project');
if (!response.body.pageInfo) new Error('Should have pagination info');
});
it('Create project', function (done) {
request(context.app)
it('Create project', async () => {
const response = await request(context.app)
.post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send({
title: 'Title1',
})
.expect(200, async (err, res) => {
if (err) return done(err);
const newProject = await Project.getByTitleOrId(res.body.id);
if (!newProject) return done('Project not created');
.expect(200);
done();
});
const newProject = await Project.getByTitleOrId(response.body.id);
if (!newProject) return new Error('Project not created');
});
it('Create projects with existing title', function (done) {
request(context.app)
it('Create projects with existing title', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/`)
.set('xc-auth', context.token)
.send({
title: project.title,
})
.expect(400, done);
.expect(400);
});
// todo: fix passport user role popluation bug
// it('Delete project', async (done) => {
// it('Delete project', async async () => {
// const toBeDeletedProject = await createProject(app, token, {
// title: 'deletedTitle',
// });
// request(app)
// await request(app)
// .delete('/api/v1/db/meta/projects/${toBeDeletedProject.id}')
// .set('xc-auth', token)
// .send({
@ -89,58 +81,48 @@ function projectTest() {
// })
// .expect(200, async (err) => {
// // console.log(res);
// if (err) return done(err);
//
// const deletedProject = await Project.getByTitleOrId(
// toBeDeletedProject.id
// );
// if (deletedProject) return done('Project not delete');
// if (deletedProject) return new Error('Project not delete');
// done();
// new Error();
// });
// });
it('Read project', (done) => {
request(context.app)
it('Read project', async () => {
const response = await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}`)
.set('xc-auth', context.token)
.send()
.expect(200, (err, res) => {
if (err) return done(err);
if (res.body.id !== project.id) return done('Got the wrong project');
.expect(200);
done();
});
if (response.body.id !== project.id) return new Error('Got the wrong project');
});
it('Update projects', function (done) {
request(context.app)
it('Update projects', async () => {
await request(context.app)
.patch(`/api/v1/db/meta/projects/${project.id}`)
.set('xc-auth', context.token)
.send({
title: 'NewTitle',
})
.expect(200, async (err) => {
if (err) {
done(err);
return;
}
const newProject = await Project.getByTitleOrId(project.id);
if (newProject.title !== 'NewTitle') {
done('Project not updated');
return;
}
done();
});
});
.expect(200);
const newProject = await Project.getByTitleOrId(project.id);
if (newProject.title !== 'NewTitle') {
return new Error('Project not updated');
}
});
it('Update projects with existing title', async function () {
const newProject = await createProject(context, {
title: 'NewTitle1',
});
return await request(context.app)
await request(context.app)
.patch(`/api/v1/db/meta/projects/${project.id}`)
.set('xc-auth', context.token)
.send({
@ -149,50 +131,42 @@ function projectTest() {
.expect(400);
});
it('Create project shared base', (done) => {
request(context.app)
it('Create project shared base', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send({
roles: 'viewer',
password: 'test',
})
.expect(200, async (err) => {
if (err) return done(err);
const updatedProject = await Project.getByTitleOrId(project.id);
.expect(200);
if (
!updatedProject.uuid ||
updatedProject.roles !== 'viewer' ||
updatedProject.password !== 'test'
) {
return done('Shared base not configured properly');
}
const updatedProject = await Project.getByTitleOrId(project.id);
done();
});
if (
!updatedProject.uuid ||
updatedProject.roles !== 'viewer' ||
updatedProject.password !== 'test'
) {
return new Error('Shared base not configured properly');
}
});
it('Created project shared base should have only editor or viewer role', (done) => {
request(context.app)
it('Created project shared base should have only editor or viewer role', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/shared`)
.set('xc-auth', context.token)
.send({
roles: 'commenter',
password: 'test',
})
.expect(200, async (err) => {
if (err) return done(err);
const updatedProject = await Project.getByTitleOrId(project.id);
.expect(200);
if (updatedProject.roles === 'commenter') {
return done('Shared base not configured properly');
}
const updatedProject = await Project.getByTitleOrId(project.id);
done();
});
if (updatedProject.roles === 'commenter') {
return new Error('Shared base not configured properly');
}
});
it('Updated project shared base should have only editor or viewer role', async () => {
@ -206,6 +180,7 @@ function projectTest() {
password: 'test',
})
.expect(200);
const updatedProject = await Project.getByTitleOrId(project.id);
if (updatedProject.roles === 'commenter') {
@ -262,29 +237,29 @@ function projectTest() {
// todo: Do compare api test
it('Meta diff sync', (done) => {
request(context.app)
it('Meta diff sync', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/meta-diff`)
.set('xc-auth', context.token)
.send()
.expect(200, done);
.expect(200);
});
it('Meta diff sync', (done) => {
request(context.app)
it('Meta diff sync', async () => {
await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/meta-diff`)
.set('xc-auth', context.token)
.send()
.expect(200, done);
.expect(200);
});
// todo: improve test. Check whether the all the actions are present in the response and correct as well
it('Meta diff sync', (done) => {
request(context.app)
it('Meta diff sync', async () => {
await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/audits`)
.set('xc-auth', context.token)
.send()
.expect(200, done);
.expect(200);
});
}

318
packages/nocodb/tests/unit/rest/tests/table.test.ts

@ -1,10 +1,10 @@
// import { expect } from 'chai';
import 'mocha';
import request from 'supertest';
import { createTable } from './factory/table';
import init from '../init';
import { createTable, getAllTables } from './factory/table';
import { createProject } from './factory/project';
import { defaultColumns } from './factory/column';
import init from '../init';
import Model from '../../../../src/lib/models/Model';
function tableTest() {
@ -19,22 +19,18 @@ function tableTest() {
table = await createTable(context, project);
});
it('Get table list', function (done) {
request(context.app)
it('Get table list', async function () {
const response = await request(context.app)
.get(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({})
.expect(200, (err, res) => {
if (err) return done(err);
.expect(200);
if (res.body.list.length !== 1) return done('Wrong number of tables');
done();
});
if (response.body.list.length !== 1) return new Error('Wrong number of tables');
});
it('Create table', function (done) {
request(context.app)
it('Create table', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
@ -42,35 +38,29 @@ function tableTest() {
title: 'new_title_2',
columns: defaultColumns,
})
.expect(200, async (err, res) => {
if (err) return done(err);
const tables = await Model.list({
project_id: project.id,
base_id: project.bases[0].id,
});
if (tables.length !== 2) {
return done('Tables is not be created');
}
if (res.body.columns.length !== defaultColumns.length) {
done('Columns not saved properly');
}
if (
!(
res.body.table_name.startsWith(project.prefix) &&
res.body.table_name.endsWith('table2')
)
) {
done('table name not configured properly');
}
done();
});
.expect(200);
const tables = await getAllTables({ project });
if (tables.length !== 2) {
return new Error('Tables is not be created');
}
if (response.body.columns.length !== defaultColumns.length) {
return new Error('Columns not saved properly');
}
if (
!(
response.body.table_name.startsWith(project.prefix) &&
response.body.table_name.endsWith('table2')
)
) {
return new Error('table name not configured properly');
}
});
it('Create table with no table name', function (done) {
request(context.app)
it('Create table with no table name', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
@ -78,35 +68,28 @@ function tableTest() {
title: 'new_title',
columns: defaultColumns,
})
.expect(400, async (err, res) => {
if (err) return done(err);
if (
!res.text.includes(
'Missing table name `table_name` property in request body'
)
) {
console.error(res.text);
return done('Wrong api response');
}
const tables = await Model.list({
project_id: project.id,
base_id: project.bases[0].id,
});
if (tables.length !== 1) {
console.log(tables);
return done(
`Tables should not be created, tables.length:${tables.length}`
);
}
done();
});
.expect(400);
if (
!response.text.includes(
'Missing table name `table_name` property in request body'
)
) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
console.log(tables);
return new Error(
`Tables should not be created, tables.length:${tables.length}`
);
}
});
it('Create table with same table name', function (done) {
request(context.app)
it('Create table with same table name', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
@ -114,28 +97,21 @@ function tableTest() {
title: 'New_title',
columns: defaultColumns,
})
.expect(400, async (err, res) => {
if (err) return done(err);
if (!res.text.includes('Duplicate table name')) {
console.error(res.text);
return done('Wrong api response');
}
const tables = await Model.list({
project_id: project.id,
base_id: project.bases[0].id,
});
if (tables.length !== 1) {
return done('Tables should not be created');
}
done();
});
.expect(400);
if (!response.text.includes('Duplicate table name')) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with same title', function (done) {
request(context.app)
it('Create table with same title', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
@ -143,28 +119,21 @@ function tableTest() {
title: table.title,
columns: defaultColumns,
})
.expect(400, async (err, res) => {
if (err) return done(err);
if (!res.text.includes('Duplicate table alias')) {
console.error(res.text);
return done('Wrong api response');
}
const tables = await Model.list({
project_id: project.id,
base_id: project.bases[0].id,
});
if (tables.length !== 1) {
return done('Tables should not be created');
}
done();
});
.expect(400);
if (!response.text.includes('Duplicate table alias')) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with title length more than the limit', function (done) {
request(context.app)
it('Create table with title length more than the limit', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
@ -172,28 +141,22 @@ function tableTest() {
title: 'new_title',
columns: defaultColumns,
})
.expect(400, async (err, res) => {
if (err) return done(err);
if (!res.text.includes('Table name exceeds ')) {
console.error(res.text);
return done('Wrong api response');
}
const tables = await Model.list({
project_id: project.id,
base_id: project.bases[0].id,
});
if (tables.length !== 1) {
return done('Tables should not be created');
}
done();
});
.expect(400);
if (!response.text.includes('Table name exceeds ')) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Create table with title having leading white space', function (done) {
request(context.app)
it('Create table with title having leading white space', async function () {
const response = await request(context.app)
.post(`/api/v1/db/meta/projects/${project.id}/tables`)
.set('xc-auth', context.token)
.send({
@ -201,113 +164,90 @@ function tableTest() {
title: 'new_title',
columns: defaultColumns,
})
.expect(400, async (err, res) => {
if (err) return done(err);
if (
!res.text.includes(
'Leading or trailing whitespace not allowed in table names'
)
) {
console.error(res.text);
return done('Wrong api response');
}
const tables = await Model.list({
project_id: project.id,
base_id: project.bases[0].id,
});
if (tables.length !== 1) {
return done('Tables should not be created');
}
done();
});
.expect(400);
if (
!response.text.includes(
'Leading or trailing whitespace not allowed in table names'
)
) {
console.error(response.text);
return new Error('Wrong api response');
}
const tables = await getAllTables({ project });
if (tables.length !== 1) {
return new Error('Tables should not be created');
}
});
it('Update table', function (done) {
request(context.app)
it('Update table', async function () {
const response = await request(context.app)
.patch(`/api/v1/db/meta/tables/${table.id}`)
.set('xc-auth', context.token)
.send({
project_id: project.id,
table_name: 'new_title',
})
.expect(200, async (err) => {
if (err) return done(err);
const updatedTable = await Model.get(table.id);
.expect(200);
const updatedTable = await Model.get(table.id);
if (!updatedTable.table_name.endsWith('new_title')) {
return done('Table was not updated');
}
done();
});
if (!updatedTable.table_name.endsWith('new_title')) {
return new Error('Table was not updated');
}
});
it('Delete table', function (done) {
request(context.app)
it('Delete table', async function () {
const response = await request(context.app)
.delete(`/api/v1/db/meta/tables/${table.id}`)
.set('xc-auth', context.token)
.send({})
.expect(200, async (err) => {
if (err) return done(err);
const tables = await Model.list({
project_id: project.id,
base_id: project.bases[0].id,
});
.expect(200);
if (tables.length !== 0) {
return done('Table is not deleted');
}
const tables = await getAllTables({ project });
done();
});
if (tables.length !== 0) {
return new Error('Table is not deleted');
}
});
// todo: Check the condtion where the table being deleted is being refered by multiple tables
// todo: Check the if views are also deleted
it('Get table', function (done) {
request(context.app)
it('Get table', async function () {
const response = await request(context.app)
.get(`/api/v1/db/meta/tables/${table.id}`)
.set('xc-auth', context.token)
.send({})
.expect(200, async (err, res) => {
if (err) return done(err);
if (res.body.id !== table.id) done('Wrong table');
.expect(200);
done();
});
if (response.body.id !== table.id) new Error('Wrong table');
});
// todo: flaky test, order condition is sometimes not met
it('Reorder table', function (done) {
it('Reorder table', async function () {
const newOrder = table.order === 0 ? 1 : 0;
request(context.app)
const response = await request(context.app)
.post(`/api/v1/db/meta/tables/${table.id}/reorder`)
.set('xc-auth', context.token)
.send({
order: newOrder,
})
.expect(200, done);
.expect(200);
// .expect(200, async (err) => {
// if (err) return done(err);
// if (err) return new Error(err);
// const updatedTable = await Model.get(table.id);
// console.log(Number(updatedTable.order), newOrder);
// if (Number(updatedTable.order) !== newOrder) {
// return done('Reordering failed');
// return new Error('Reordering failed');
// }
// done();
// new Error();
// });
});
}
export default function () {
export default async function () {
describe('Table', tableTest);
}

18
packages/nocodb/tests/unit/rest/tests/tableRow.test.ts

@ -1,6 +1,6 @@
import 'mocha';
import { createProject, createSakilaProject } from './factory/project';
import init from '../init';
import { createProject, createSakilaProject } from './factory/project';
import request from 'supertest';
import { ColumnType, UITypes } from 'nocodb-sdk';
import {
@ -1167,22 +1167,6 @@ function tableTest() {
}
});
it('Delete table row', async function () {
const table = await createTable(context, project);
const row = await createRow(context, { project, table });
await request(context.app)
.delete(`/api/v1/db/data/noco/${project.id}/${table.id}/${row['Id']}`)
.set('xc-auth', context.token)
.expect(200);
const deleteRow = await getRow(context, {project, table, id: row['Id']});
if (deleteRow && Object.keys(deleteRow).length > 0) {
console.log(deleteRow);
throw new Error('Wrong delete');
}
});
it('Delete table row with foreign key contraint', async function () {
const table = await createTable(context, project);
const relatedTable = await createTable(context, project, {

537
packages/nocodb/tests/unit/rest/tests/viewRow.test.ts

@ -1,6 +1,6 @@
import 'mocha';
import { createProject, createSakilaProject } from './factory/project';
import init from '../init';
import { createProject, createSakilaProject } from './factory/project';
import request from 'supertest';
import Project from '../../../../src/lib/models/Project';
import Model from '../../../../src/lib/models/Model';
@ -8,8 +8,11 @@ import { createTable, getTable } from './factory/table';
import View from '../../../../src/lib/models/View';
import { ColumnType, UITypes, ViewType, ViewTypes } from 'nocodb-sdk';
import { createView } from './factory/view';
import { createLookupColumn, createRollupColumn } from './factory/column';
import { createColumn, createLookupColumn, createLtarColumn, createRollupColumn, updateViewColumn } from './factory/column';
import Audit from '../../../../src/lib/models/Audit';
import Column from '../../../../src/lib/models/Column';
import GalleryView from '../../../../src/lib/models/GalleryView';
import { createRelation, createRow, getOneRow, getRow } from './factory/row';
const isColumnsCorrectInResponse = (row, columns: ColumnType[]) => {
const responseColumnsListStr = Object.keys(row).sort().join(',');
@ -33,7 +36,6 @@ function viewRowTests() {
beforeEach(async function () {
context = await init();
sakilaProject = await createSakilaProject(context);
project = await createProject(context);
customerTable = await getTable({project: sakilaProject, name: 'customer'})
@ -609,13 +611,8 @@ function viewRowTests() {
it('Find one sorted data list with required columns grid', async function () {
await testFindOneSortedDataWithRequiredColumns(ViewTypes.GRID);
});
const testFindOneSortedFilteredNestedFieldsDataWithRollup = async (viewType: ViewTypes) => {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
const rollupColumn = await createRollupColumn(context, {
project: sakilaProject,
title: 'Number of rentals',
@ -624,7 +621,18 @@ function viewRowTests() {
relatedTableName: 'rental',
relatedTableColumnTitle: 'RentalDate',
});
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
await updateViewColumn(context, {
column: rollupColumn,
view: view,
attr: {show: true},
})
const paymentListColumn = (await customerTable.getColumns()).find(
(c) => c.title === 'Payment List'
);
@ -693,7 +701,7 @@ function viewRowTests() {
.expect(200);
if (ascResponse.body[rollupColumn.title] !== 12) {
console.log(ascResponse.body);
console.log('response.body',ascResponse.body);
throw new Error('Wrong filter');
}
@ -708,13 +716,518 @@ function viewRowTests() {
}
// todo: gallery view doesnt seem to support rollup
// it.only('Find one sorted filtered view with nested fields data list with a rollup column in customer table FORM', async function () {
// it.only('Find one sorted filtered view with nested fields data list with a rollup column in customer table GALLERY', async function () {
// await testFindOneSortedFilteredNestedFieldsDataWithRollup(ViewTypes.GALLERY);
// });
// it('Find one sorted filtered view with nested fields data list with a rollup column in customer table FORM', async function () {
// await testFindOneSortedFilteredNestedFieldsDataWithRollup(ViewTypes.FORM);
// });
it('Find one sorted filtered view with nested fields data list with a rollup column in customer table GRID', async function () {
it('Find one view sorted filtered view with nested fields data list with a rollup column in customer table GRID', async function () {
await testFindOneSortedFilteredNestedFieldsDataWithRollup(ViewTypes.GRID);
});
const testGroupDescSorted = async (viewType: ViewTypes) => {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
const firstNameColumn = customerColumns.find(
(col) => col.title === 'FirstName'
);
const rollupColumn = await createRollupColumn(context, {
project: sakilaProject,
title: 'Rollup',
rollupFunction: 'count',
table: customerTable,
relatedTableName: 'rental',
relatedTableColumnTitle: 'RentalDate',
});
const visibleColumns = [firstNameColumn];
const sortInfo = `-FirstName, +${rollupColumn.title}`;
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/groupby`
)
.set('xc-auth', context.token)
.query({
fields: visibleColumns.map((c) => c.title),
sort: sortInfo,
column_name: firstNameColumn.column_name,
})
.expect(200);
if (
response.body.list[4]['first_name'] !== 'WILLIE' ||
response.body.list[4]['count'] !== 2
)
throw new Error('Wrong groupby');
}
it('Groupby desc sorted and with rollup view data list with required columns GRID', async function () {
await testGroupDescSorted(ViewTypes.GRID);
});
it('Groupby desc sorted and with rollup view data list with required columns FORM', async function () {
await testGroupDescSorted(ViewTypes.FORM);
});
it('Groupby desc sorted and with rollup view data list with required columns GALLERY', async function () {
await testGroupDescSorted(ViewTypes.GALLERY);
});
const testGroupWithOffset = async (viewType: ViewTypes) => {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
const firstNameColumn = customerColumns.find(
(col) => col.title === 'FirstName'
);
const rollupColumn = await createRollupColumn(context, {
project: sakilaProject,
title: 'Rollup',
rollupFunction: 'count',
table: customerTable,
relatedTableName: 'rental',
relatedTableColumnTitle: 'RentalDate',
});
const visibleColumns = [firstNameColumn];
const sortInfo = `-FirstName, +${rollupColumn.title}`;
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/groupby`
)
.set('xc-auth', context.token)
.query({
fields: visibleColumns.map((c) => c.title),
sort: sortInfo,
column_name: firstNameColumn.column_name,
offset: 4,
})
.expect(200);
if (
response.body.list[0]['first_name'] !== 'WILLIE' ||
response.body.list[0]['count'] !== 2
)
throw new Error('Wrong groupby');
}
it('Groupby desc sorted and with rollup view data list with required columns GALLERY', async function () {
await testGroupWithOffset(ViewTypes.GALLERY);
});
it('Groupby desc sorted and with rollup view data list with required columns FORM', async function () {
await testGroupWithOffset(ViewTypes.FORM);
});
it('Groupby desc sorted and with rollup view data list with required columns GRID', async function () {
await testGroupWithOffset(ViewTypes.GRID);
});
const testCount = async (viewType: ViewTypes) => {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/count`)
.set('xc-auth', context.token)
.expect(200);
if(response.body.count !== 599) {
throw new Error('Wrong count');
}
}
it('Count view data list with required columns GRID', async function () {
await testCount(ViewTypes.GRID);
});
it('Count view data list with required columns FORM', async function () {
await testCount(ViewTypes.FORM);
});
it('Count view data list with required columns GALLERY', async function () {
await testCount(ViewTypes.GALLERY);
});
const testReadViewRow = async (viewType: ViewTypes) => {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
const listResponse = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}`)
.set('xc-auth', context.token)
.expect(200);
const row = listResponse.body.list[0];
const readResponse = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/${row['CustomerId']}`
)
.set('xc-auth', context.token)
.expect(200);
if (
row['CustomerId'] !== readResponse.body['CustomerId'] ||
row['FirstName'] !== readResponse.body['FirstName']
) {
throw new Error('Wrong read');
}
}
it('Read view row GALLERY', async function () {
await testReadViewRow(ViewTypes.GALLERY);
})
it('Read view row FORM', async function () {
await testReadViewRow(ViewTypes.FORM);
})
it('Read view row GRID', async function () {
await testReadViewRow(ViewTypes.GRID);
})
const testUpdateViewRow = async (viewType: ViewTypes) => {
const table = await createTable(context, project);
const row = await createRow(context, { project, table });
const view = await createView(context, {
title: 'View',
table: table,
type: viewType
});
const updateResponse = await request(context.app)
.patch(`/api/v1/db/data/noco/${project.id}/${table.id}/views/${view.id}/${row['Id']}`)
.set('xc-auth', context.token)
.send({
title: 'Updated',
})
.expect(200);
if (updateResponse.body['Title'] !== 'Updated') {
throw new Error('Wrong update');
}
}
it('Update view row GALLERY', async function () {
await testUpdateViewRow(ViewTypes.GALLERY);
})
it('Update view row GRID', async function () {
await testUpdateViewRow(ViewTypes.GRID);
})
it('Update view row FORM', async function () {
await testUpdateViewRow(ViewTypes.FORM);
})
const testUpdateViewRowWithValidationAndInvalidData = async (viewType: ViewTypes) => {
const table = await createTable(context, project);
const emailColumn = await createColumn(context, table, {
title: 'Email',
column_name: 'email',
uidt: UITypes.Email,
meta: {
validate: true,
},
});
const view = await createView(context, {
title: 'View',
table: table,
type: viewType
});
const row = await createRow(context, { project, table });
await request(context.app)
.patch(`/api/v1/db/data/noco/${project.id}/${table.id}/views/${view.id}/${row['Id']}`)
.set('xc-auth', context.token)
.send({
[emailColumn.column_name]: 'invalidemail',
})
.expect(400);
}
it('Update view row with validation and invalid data GALLERY', async function () {
await testUpdateViewRowWithValidationAndInvalidData(ViewTypes.GALLERY);
})
it('Update view row with validation and invalid data GRID', async function () {
await testUpdateViewRowWithValidationAndInvalidData(ViewTypes.GRID);
})
it('Update view row with validation and invalid data FORM', async function () {
await testUpdateViewRowWithValidationAndInvalidData(ViewTypes.FORM);
})
// todo: Test webhooks of before and after update
// todo: Test with form view
const testUpdateViewRowWithValidationAndValidData = async (viewType: ViewTypes) => {
const table = await createTable(context, project);
const emailColumn = await createColumn(context, table, {
title: 'Email',
column_name: 'email',
uidt: UITypes.Email,
meta: {
validate: true,
},
});
const view = await createView(context, {
title: 'View',
table: table,
type: viewType
});
const row = await createRow(context, { project, table });
const response = await request(context.app)
.patch(`/api/v1/db/data/noco/${project.id}/${table.id}/views/${view.id}/${row['Id']}`)
.set('xc-auth', context.token)
.send({
[emailColumn.column_name]: 'valid@example.com',
})
.expect(200);
const updatedRow = await getRow(
context,
{project,
table,
id: response.body['Id']}
);
if (updatedRow[emailColumn.title] !== 'valid@example.com') {
throw new Error('Wrong update');
}
}
it('Update view row with validation and valid data GALLERY', async function () {
await testUpdateViewRowWithValidationAndValidData(ViewTypes.GALLERY);
})
it('Update view row with validation and valid data GRID', async function () {
await testUpdateViewRowWithValidationAndValidData(ViewTypes.GRID);
})
it('Update view row with validation and valid data FORM', async function () {
await testUpdateViewRowWithValidationAndValidData(ViewTypes.FORM);
})
const testDeleteViewRow = async (viewType: ViewTypes) => {
const table = await createTable(context, project);
const row = await createRow(context, { project, table });
const view = await createView(context, {
title: 'View',
table: table,
type: viewType
});
await request(context.app)
.delete(`/api/v1/db/data/noco/${project.id}/${table.id}/views/${view.id}/${row['Id']}`)
.set('xc-auth', context.token)
.expect(200);
const deleteRow = await getRow(context, {project, table, id: row['Id']});
if (deleteRow && Object.keys(deleteRow).length > 0) {
console.log(deleteRow);
throw new Error('Wrong delete');
}
}
it('Delete view row GALLERY', async function () {
await testDeleteViewRow(ViewTypes.GALLERY);
})
it('Delete view row GRID', async function () {
await testDeleteViewRow(ViewTypes.GRID);
})
it('Delete view row FORM', async function () {
await testDeleteViewRow(ViewTypes.FORM);
})
const testDeleteViewRowWithForiegnKeyConstraint = async (viewType: ViewTypes) => {
const table = await createTable(context, project);
const relatedTable = await createTable(context, project, {
table_name: 'Table2',
title: 'Table2_Title',
});
const ltarColumn = await createLtarColumn(context, {
title: 'Ltar',
parentTable: table,
childTable: relatedTable,
type: 'hm',
});
const view = await createView(context, {
title: 'View',
table: table,
type: viewType
});
const row = await createRow(context, { project, table });
await createRelation(context, {
project,
table,
childTable: relatedTable,
column: ltarColumn,
type: 'hm',
rowId: row['Id'],
});
const response = await request(context.app)
.delete(`/api/v1/db/data/noco/${project.id}/${table.id}/views/${view.id}/${row['Id']}`)
.set('xc-auth', context.token)
.expect(200);
const deleteRow = await getRow(context, {project, table, id: row['Id']});
if (!deleteRow) {
throw new Error('Should not delete');
}
if (
!(response.body.message[0] as string).includes(
'is a LinkToAnotherRecord of'
)
) {
throw new Error('Should give ltar foreign key error');
}
}
it('Delete view row with ltar foreign key constraint GALLERY', async function () {
await testDeleteViewRowWithForiegnKeyConstraint(ViewTypes.GALLERY);
})
it('Delete view row with ltar foreign key constraint GRID', async function () {
await testDeleteViewRowWithForiegnKeyConstraint(ViewTypes.GRID);
})
it('Delete view row with ltar foreign key constraint FORM', async function () {
await testDeleteViewRowWithForiegnKeyConstraint(ViewTypes.FORM);
})
const testViewRowExists = async (viewType: ViewTypes) => {
const row = await getOneRow(context, {
project: sakilaProject,
table: customerTable,
});
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/${row['CustomerId']}/exist`
)
.set('xc-auth', context.token)
.expect(200);
if (!response.body) {
throw new Error('Should exist');
}
}
it('Exist should be true view row when it exists GALLERY', async function () {
await testViewRowExists(ViewTypes.GALLERY);
});
it('Exist should be true view row when it exists GRID', async function () {
await testViewRowExists(ViewTypes.GRID);
})
it('Exist should be true view row when it exists FORM', async function () {
await testViewRowExists(ViewTypes.FORM);
})
const testViewRowNotExists = async (viewType: ViewTypes) => {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: viewType
});
const response = await request(context.app)
.get(
`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.id}/views/${view.id}/invalid-id/exist`
)
.set('xc-auth', context.token)
.expect(200);
if (response.body) {
throw new Error('Should not exist');
}
}
it('Exist should be false view row when it does not exist GALLERY', async function () {
await testViewRowNotExists(ViewTypes.GALLERY);
})
it('Exist should be false view row when it does not exist GRID', async function () {
await testViewRowNotExists(ViewTypes.GRID);
})
it('Exist should be false view row when it does not exist FORM', async function () {
await testViewRowNotExists(ViewTypes.FORM);
})
it('Export csv GRID', async function () {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: ViewTypes.GRID
});
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.title}/views/${view.id}/export/csv`)
.set('xc-auth', context.token)
.expect(200);
if(!response['header']['content-disposition'].includes("View-export.csv")){
console.log(response['header']['content-disposition']);
throw new Error('Wrong file name');
}
if(!response.text){
throw new Error('Wrong export');
}
})
it('Export excel GRID', async function () {
const view = await createView(context, {
title: 'View',
table: customerTable,
type: ViewTypes.GRID
});
const response = await request(context.app)
.get(`/api/v1/db/data/noco/${sakilaProject.id}/${customerTable.title}/views/${view.id}/export/excel`)
.set('xc-auth', context.token)
.expect(200);
if(!response['header']['content-disposition'].includes("View-export.xlsx")){
console.log(response['header']['content-disposition']);
throw new Error('Wrong file name');
}
if(!response.text){
throw new Error('Wrong export');
}
})
}
export default function () {

Loading…
Cancel
Save