teableio/teable

View on GitHub
apps/nestjs-backend/test/share.e2e-spec.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { type INestApplication } from '@nestjs/common';
import type {
  IFieldRo,
  IFilterRo,
  ILinkFieldOptions,
  IRecord,
  IUserFieldOptions,
  IViewRo,
} from '@teable/core';
import {
  ANONYMOUS_USER_ID,
  FieldKeyType,
  FieldType,
  is,
  Relationship,
  ViewType,
} from '@teable/core';
import {
  urlBuilder,
  SHARE_VIEW_GET,
  SHARE_VIEW_FORM_SUBMIT,
  createRecords as apiCreateRecords,
  deleteRecords as apiDeleteRecords,
  enableShareView as apiEnableShareView,
  getShareViewLinkRecords as apiGetShareViewLinkRecords,
  getShareViewCollaborators as apiGetShareViewCollaborators,
  getBaseCollaboratorList as apiGetBaseCollaboratorList,
  updateViewColumnMeta as apiUpdateViewColumnMeta,
  updateViewShareMeta as apiUpdateViewShareMeta,
  SHARE_VIEW_COPY,
  SHARE_VIEW_AUTH,
  getShareView,
  createField,
  updateViewShareMeta,
  shareViewFormSubmit,
  deleteView,
} from '@teable/openapi';
import type { ITableFullVo, ShareViewAuthVo, ShareViewGetVo } from '@teable/openapi';
import { map } from 'lodash';
import { x_20 } from './data-helpers/20x';
import { createAnonymousUserAxios } from './utils/axios-instance/anonymous-user';
import { createNewUserAxios } from './utils/axios-instance/new-user';
import { getError } from './utils/get-error';
import {
  createTable,
  createView,
  permanentDeleteTable,
  initApp,
  updateViewColumnMeta,
  updateViewFilter,
  getField,
  deleteField,
  convertField,
} from './utils/init-app';

const formViewRo: IViewRo = {
  name: 'Form view',
  description: 'the form view',
  type: ViewType.Form,
};

const gridViewRo: IViewRo = {
  name: 'Grid view',
  description: 'the grid view',
  type: ViewType.Grid,
};

describe('OpenAPI ShareController (e2e)', () => {
  let app: INestApplication;
  let tableId: string;
  let shareId: string;
  let viewId: string;
  const baseId = globalThis.testConfig.baseId;
  const userId = globalThis.testConfig.userId;
  const userName = globalThis.testConfig.userName;
  const userEmail = globalThis.testConfig.email;
  let fieldIds: string[] = [];
  let anonymousUser: ReturnType<typeof createAnonymousUserAxios>;

  beforeAll(async () => {
    const appCtx = await initApp();
    app = appCtx.app;
    anonymousUser = createAnonymousUserAxios(appCtx.appUrl);

    const table = await createTable(baseId, { name: 'table1' });

    tableId = table.id;
    viewId = table.defaultViewId!;

    const shareResult = await apiEnableShareView({ tableId, viewId });
    fieldIds = map(table.fields, 'id');
    // hidden last one field
    const field = table.fields[fieldIds.length - 1];
    await updateViewColumnMeta(tableId, viewId, [
      { fieldId: field.id, columnMeta: { hidden: true } },
    ]);
    shareId = shareResult.data.shareId;
  });

  afterAll(async () => {
    await permanentDeleteTable(baseId, tableId);

    await app.close();
  });

  describe('api/:shareId/view (GET)', async () => {
    it('should return view', async () => {
      const result = await anonymousUser.get<ShareViewGetVo>(
        urlBuilder(SHARE_VIEW_GET, { shareId })
      );
      const shareViewData = result.data;
      // filter hidden field
      expect(shareViewData.fields.length).toEqual(fieldIds.length - 1);
      expect(shareViewData.viewId).toEqual(viewId);
    });

    it('records return [] in not includeRecords', async () => {
      const result = await createView(tableId, gridViewRo);
      const viewId = result.id;
      const shareResult = await apiEnableShareView({ tableId, viewId });
      await updateViewShareMeta(tableId, viewId, { includeRecords: false });
      const viewShareId = shareResult.data.shareId;
      const resultData = await anonymousUser.get<ShareViewGetVo>(
        urlBuilder(SHARE_VIEW_GET, { shareId: viewShareId })
      );
      expect(resultData.data.records).toEqual([]);
    });

    it('password in grid view', async () => {
      const result = await createView(tableId, gridViewRo);
      const gridViewId = result.id;
      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });
      const gridViewShareId = shareResult.data.shareId;
      await apiUpdateViewShareMeta(tableId, gridViewId, { password: '123123123' });
      const error = await getError(() =>
        anonymousUser.get<ShareViewGetVo>(urlBuilder(SHARE_VIEW_GET, { shareId: gridViewShareId }))
      );
      expect(error?.status).toEqual(401);
    });

    it('password in grid view had auth', async () => {
      const result = await createView(tableId, gridViewRo);
      const gridViewId = result.id;
      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });
      const gridViewShareId = shareResult.data.shareId;
      await apiUpdateViewShareMeta(tableId, gridViewId, { password: '123123123' });
      const res = await anonymousUser.post<ShareViewAuthVo>(
        urlBuilder(SHARE_VIEW_AUTH, { shareId: gridViewShareId }),
        {
          password: '123123123',
        }
      );
      const resultData = await anonymousUser.get<ShareViewGetVo>(
        urlBuilder(SHARE_VIEW_GET, { shareId: gridViewShareId }),
        {
          headers: {
            cookie: res.headers['set-cookie'],
          },
        }
      );
      expect(resultData.data.viewId).toEqual(gridViewId);
    });
  });

  describe('api/:shareId/view/form-submit (POST)', () => {
    let formViewId: string;
    let fromViewShareId: string;

    beforeEach(async () => {
      const result = await createView(tableId, formViewRo);
      formViewId = result.id;

      const shareResult = await apiEnableShareView({ tableId, viewId: formViewId });
      fromViewShareId = shareResult.data.shareId;
    });

    it('submit form view', async () => {
      const result = await anonymousUser.post(
        urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: fromViewShareId }),
        {
          fields: {},
        }
      );
      const record = result.data as IRecord;
      expect(record.createdBy).toEqual(ANONYMOUS_USER_ID);
    });

    it('submit exclude form view', async () => {
      const result = await createView(tableId, gridViewRo);
      const gridViewId = result.id;
      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });
      const gridViewShareId = shareResult.data.shareId;
      const error = await getError(() =>
        anonymousUser.post(urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: gridViewShareId }), {
          fields: {},
        })
      );
      expect(error?.status).toEqual(403);
    });

    it('submit include hidden field', async () => {
      const hiddenFieldId = fieldIds[fieldIds.length - 1];
      await updateViewColumnMeta(tableId, formViewId, [
        { fieldId: fieldIds[fieldIds.length - 1], columnMeta: { visible: false } },
      ]);
      const error = await getError(() =>
        anonymousUser.post(urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: fromViewShareId }), {
          fields: {
            [hiddenFieldId]: null,
          },
        })
      );
      expect(error?.status).toEqual(403);
    });

    it('required login', async () => {
      await updateViewShareMeta(tableId, formViewId, {
        submit: {
          requireLogin: true,
          allow: true,
        },
      });
      const error = await getError(() =>
        anonymousUser.post(urlBuilder(SHARE_VIEW_FORM_SUBMIT, { shareId: fromViewShareId }), {
          fields: {},
        })
      );
      expect(error?.status).toEqual(401);
      const res = await shareViewFormSubmit({
        shareId: fromViewShareId,
        fields: {},
      });
      expect(res.status).toEqual(201);
    });
  });

  describe('api/:shareId/view/link-records (GET)', () => {
    let linkTableRes: ITableFullVo;
    const primaryFieldName = 'Text1';
    let linkFieldId: string;
    let tableRes: ITableFullVo;

    const tableRecords = [
      { fields: { [primaryFieldName]: '1' } },
      { fields: { [primaryFieldName]: '2' } },
      { fields: { [primaryFieldName]: '3' } },
    ];

    beforeAll(async () => {
      tableRes = await createTable(baseId, {
        records: tableRecords,
        fields: [
          {
            name: primaryFieldName,
            type: FieldType.SingleLineText,
          },
        ],
      });
      const linkFieldRo: IFieldRo = {
        name: 'link field',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyOne,
          foreignTableId: tableRes.id,
        },
      };

      linkTableRes = await createTable(baseId, {
        name: 'linkTable',
        fields: [
          {
            name: 'primary',
            type: FieldType.SingleLineText,
          },
          linkFieldRo,
        ],
        records: [
          { fields: { primary: '1', [linkFieldRo.name!]: { id: tableRes.records[0].id } } },
          { fields: { primary: '2', [linkFieldRo.name!]: { id: tableRes.records[1].id } } },
        ],
      });
      linkFieldId = linkTableRes.fields[1].id;
    });

    afterAll(async () => {
      await permanentDeleteTable(baseId, linkTableRes.id);
      await permanentDeleteTable(baseId, tableRes.id);
    });

    describe('form view', () => {
      let formViewId: string;
      let fromViewShareId: string;
      beforeAll(async () => {
        const result = await createView(linkTableRes.id, formViewRo);
        formViewId = result.id;
        await apiUpdateViewColumnMeta(linkTableRes.id, formViewId, [
          {
            fieldId: linkFieldId,
            columnMeta: { visible: true },
          },
        ]);
        const shareResult = await apiEnableShareView({
          tableId: linkTableRes.id,
          viewId: formViewId,
        });
        fromViewShareId = shareResult.data.shareId;
      });
      it('should return link records', async () => {
        const result = await apiGetShareViewLinkRecords(fromViewShareId, {
          fieldId: linkFieldId,
        });
        const linkRecords = result.data;
        expect(linkRecords.map((record) => record.title)).toEqual(
          tableRecords.map((record) => record.fields[primaryFieldName])
        );
      });
    });

    describe('grid view', () => {
      let gridViewId: string;
      let gridViewShareId: string;
      beforeAll(async () => {
        const result = await createView(linkTableRes.id, gridViewRo);
        gridViewId = result.id;
        const shareResult = await apiEnableShareView({
          tableId: linkTableRes.id,
          viewId: gridViewId,
        });
        gridViewShareId = shareResult.data.shareId;
      });

      it('should return link records', async () => {
        const result = await apiGetShareViewLinkRecords(gridViewShareId, {
          fieldId: linkFieldId,
        });
        const linkRecords = result.data;
        expect(linkRecords.map((record) => record.title)).toEqual(
          tableRecords.slice(0, 2).map((record) => record.fields[primaryFieldName])
        );
      });
    });
  });

  describe('api/:shareId/view/collaborators (GET)', () => {
    let userTableRes: ITableFullVo;
    const userFieldName = 'normal user';
    const multipleUserFieldName = 'multiple user';
    let userFieldId: string;
    let multipleUserFieldId: string;
    const userFieldRo: IFieldRo = {
      name: userFieldName,
      type: FieldType.User,
      options: {
        isMultiple: false,
        shouldNotify: false,
      } as IUserFieldOptions,
    };

    const multipleUserFieldRo: IFieldRo = {
      name: multipleUserFieldName,
      type: FieldType.User,
      options: {
        isMultiple: true,
        shouldNotify: false,
      } as IUserFieldOptions,
    };
    beforeAll(async () => {
      userTableRes = await createTable(baseId, {
        name: 'user table',
        fields: [
          {
            name: 'primary',
            type: FieldType.SingleLineText,
          },
          userFieldRo,
          multipleUserFieldRo,
        ],
        records: [],
      });
      userFieldId = userTableRes.fields[1].id;
      multipleUserFieldId = userTableRes.fields[2].id;
    });

    afterAll(async () => {
      await permanentDeleteTable(baseId, userTableRes.id);
    });
    describe('grid view', () => {
      let gridViewId: string;
      let gridViewShareId: string;
      beforeAll(async () => {
        const result = await createView(userTableRes.id, gridViewRo);
        gridViewId = result.id;
        const shareResult = await apiEnableShareView({
          tableId: userTableRes.id,
          viewId: gridViewId,
        });
        gridViewShareId = shareResult.data.shareId;
      });
      it('should return [], no user cell with a value exists', async () => {
        const result = await apiGetShareViewCollaborators(gridViewShareId, {
          fieldId: userFieldId,
        });
        expect(result.data).toEqual([]);
      });

      it('should return the value that exists and there will be no duplicates of the', async () => {
        const { data: createRes } = await apiCreateRecords(userTableRes.id, {
          records: [
            {
              fields: {
                [multipleUserFieldId]: [{ id: userId, title: userName }],
                [userFieldId]: { id: userId, title: userName },
              },
            },
            {
              fields: {
                [multipleUserFieldId]: [{ id: userId, title: userName }],
                [userFieldId]: { id: userId, title: userName },
              },
            },
          ],
          fieldKeyType: FieldKeyType.Id,
        });
        const result = await apiGetShareViewCollaborators(gridViewShareId, {
          fieldId: userFieldId,
        });
        const mulResult = await apiGetShareViewCollaborators(gridViewShareId, {
          fieldId: multipleUserFieldId,
        });
        expect(result.data).toEqual([{ userId, userName, email: userEmail, avatar: null }]);
        expect(mulResult.data).toEqual([{ userId, userName, email: userEmail, avatar: null }]);

        await apiDeleteRecords(
          userTableRes.id,
          createRes.records.map((record) => record.id)
        );
      });
    });

    describe('Form view', () => {
      let formViewId: string;
      let fromViewShareId: string;
      beforeAll(async () => {
        const result = await createView(userTableRes.id, formViewRo);
        formViewId = result.id;
        const shareResult = await apiEnableShareView({
          tableId: userTableRes.id,
          viewId: formViewId,
        });
        fromViewShareId = shareResult.data.shareId;
      });
      it('should return [], no user cell visible', async () => {
        const result = await apiGetShareViewCollaborators(fromViewShareId, {
          fieldId: userFieldId,
        });
        expect(result.data).toEqual([]);
      });
      it('should return the base collaborators', async () => {
        await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [
          {
            fieldId: userFieldId,
            columnMeta: { visible: true },
          },
        ]);
        const result = await apiGetShareViewCollaborators(fromViewShareId, {});
        const baseCollaborators = await apiGetBaseCollaboratorList(baseId);
        expect(result.data.map((user) => user.userId)).toEqual(
          baseCollaborators.data.map((user) => user.userId)
        );
        await apiUpdateViewColumnMeta(userTableRes.id, formViewId, [
          {
            fieldId: userFieldId,
            columnMeta: { visible: false },
          },
        ]);
      });
    });
  });

  describe('api/:shareId/view/copy (PATCH)', () => {
    let gridViewId: string;
    let gridViewShareId: string;

    beforeEach(async () => {
      const result = await createView(tableId, gridViewRo);
      gridViewId = result.id;

      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });
      await apiUpdateViewShareMeta(tableId, gridViewId, { allowCopy: true });
      gridViewShareId = shareResult.data.shareId;
    });

    it('should return 200', async () => {
      const result = await anonymousUser.get(
        urlBuilder(SHARE_VIEW_COPY, { shareId: gridViewShareId }),
        {
          params: {
            ranges: JSON.stringify([
              [0, 0],
              [1, 1],
            ]),
          },
        }
      );
      expect(result.status).toEqual(200);
    });

    it('share not allow copy', async () => {
      const result = await createView(tableId, gridViewRo);
      const gridViewId = result.id;

      const shareResult = await apiEnableShareView({ tableId, viewId: gridViewId });
      const gridViewShareId = shareResult.data.shareId;
      const error = await getError(() =>
        anonymousUser.get(urlBuilder(SHARE_VIEW_COPY, { shareId: gridViewShareId }), {
          params: {
            ranges: JSON.stringify([
              [0, 0],
              [1, 1],
            ]),
          },
        })
      );
      expect(error?.status).toEqual(403);
    });
  });

  describe('link view permission', () => {
    let table1: ITableFullVo;
    let table2: ITableFullVo;

    beforeEach(async () => {
      table1 = await createTable(baseId, { name: 'table1' });
      table2 = await createTable(baseId, { name: 'table2' });
    });

    afterEach(async () => {
      await permanentDeleteTable(baseId, table1.id);
      await permanentDeleteTable(baseId, table2.id);
    });

    it('should get link view', async () => {
      const linkField = await createField(table1.id, {
        name: 'link field',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyOne,
          foreignTableId: table2.id,
        },
      });
      const shareResult = await getShareView(linkField.data.id);

      // should not allow access by other user
      const user2Request = await createNewUserAxios({
        email: 'newuser@example.com',
        password: '12345678',
      });
      expect(
        user2Request.get(urlBuilder(SHARE_VIEW_GET, { shareId: shareResult.data.shareId }))
      ).rejects.toThrow();
    });
  });

  describe('link view limit', () => {
    let table1: ITableFullVo;
    let table2: ITableFullVo;

    beforeEach(async () => {
      table1 = await createTable(baseId, { name: 'table1' });
      table2 = await createTable(baseId, {
        name: 'table2',
        fields: x_20.fields,
        records: x_20.records,
      });
    });

    afterEach(async () => {
      await permanentDeleteTable(baseId, table1.id);
      await permanentDeleteTable(baseId, table2.id);
    });

    it('should get link view limit by view', async () => {
      const filterByViewId = table2.defaultViewId;
      const singleSelectField = table2.fields[2];
      const filter: IFilterRo = {
        filter: {
          conjunction: 'and',
          filterSet: [
            {
              fieldId: singleSelectField.id,
              operator: is.value,
              value: 'x',
            },
          ],
        },
      };

      await updateViewFilter(table2.id, table2.defaultViewId!, filter);

      const linkField = await createField(table1.id, {
        name: 'link field limit by view',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyMany,
          foreignTableId: table2.id,
          filterByViewId,
        },
      });
      const shareResult = await getShareView(linkField.data.id);

      expect(shareResult.data.records.length).toEqual(7);
    });

    it('should get link view limit by filter', async () => {
      const singleSelectField = table2.fields[2];
      const filter = {
        conjunction: 'and',
        filterSet: [
          {
            fieldId: singleSelectField.id,
            operator: is.value,
            value: 'x',
          },
        ],
      };
      const linkField = await createField(table1.id, {
        name: 'link field limit by filter',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyMany,
          foreignTableId: table2.id,
          filter,
        },
      });
      const shareResult = await getShareView(linkField.data.id);

      expect(shareResult.data.records.length).toEqual(7);
    });

    it('should get link view limit by visible fields', async () => {
      const fields = table2.fields;
      const visibleFieldIds = fields.slice(0, 3).map((field) => field.id);
      const linkField = await createField(table1.id, {
        name: 'link field limit by hidden fields',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyMany,
          foreignTableId: table2.id,
          visibleFieldIds,
        },
      });
      const shareResult = await getShareView(linkField.data.id);

      expect(shareResult.data.fields.length).toEqual(3);
    });

    it('should get link view limited by multiple conditions', async () => {
      const filterByViewId = table2.defaultViewId;
      const textField = table2.fields[0];
      const singleSelectField = table2.fields[2];
      const filter: IFilterRo = {
        filter: {
          conjunction: 'and',
          filterSet: [
            {
              fieldId: singleSelectField.id,
              operator: is.value,
              value: 'x',
            },
          ],
        },
      };

      await updateViewFilter(table2.id, table2.defaultViewId!, filter);

      const fields = table2.fields;
      const visibleFieldIds = fields.slice(0, 3).map((field) => field.id);

      const additionalFilter = {
        conjunction: 'and',
        filterSet: [
          {
            fieldId: textField.id,
            operator: is.value,
            value: '6',
          },
        ],
      };

      const linkField = await createField(table1.id, {
        name: 'link field with multiple limits',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyMany,
          foreignTableId: table2.id,
          filterByViewId,
          filter: additionalFilter,
          visibleFieldIds,
        },
      });
      const shareResult = await getShareView(linkField.data.id);

      expect(shareResult.data.records.length).toBeLessThanOrEqual(1);
      expect(shareResult.data.fields.length).toEqual(3);
    });

    it('should clean link options after filterByViewId is deleted', async () => {
      const view = await createView(table2.id, {
        name: 'view',
        type: ViewType.Grid,
      });

      const linkField = await createField(table1.id, {
        name: 'clean link options filterByViewId',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyMany,
          foreignTableId: table2.id,
          filterByViewId: view.id,
        },
      });

      expect((linkField.data.options as ILinkFieldOptions).filterByViewId).toEqual(view.id);

      await deleteView(table2.id, view.id);
      const currentLinkField = await getField(table1.id, linkField.data.id);

      expect((currentLinkField.options as ILinkFieldOptions).filterByViewId).toBeNull();
    });

    it('should clean link options after filtering field is deleted', async () => {
      const singleSelectField = table2.fields[2];
      const filter = {
        conjunction: 'and',
        filterSet: [
          {
            fieldId: singleSelectField.id,
            operator: is.value,
            value: 'x',
          },
        ],
      };

      const linkField = await createField(table1.id, {
        name: 'clean link options filter',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyMany,
          foreignTableId: table2.id,
          filter,
          visibleFieldIds: [singleSelectField.id],
        },
      });

      expect((linkField.data.options as ILinkFieldOptions).filter).toEqual(filter);
      expect((linkField.data.options as ILinkFieldOptions).visibleFieldIds).toEqual([
        singleSelectField.id,
      ]);

      await deleteField(table2.id, singleSelectField.id);
      const currentLinkField = await getField(table1.id, linkField.data.id);

      expect((currentLinkField.options as ILinkFieldOptions).filter).toBeNull();
      expect((currentLinkField.options as ILinkFieldOptions).visibleFieldIds).toBeNull();
    });

    it('should clean link options after filtering field is converted', async () => {
      const singleSelectField = table2.fields[2];
      const filter = {
        conjunction: 'and',
        filterSet: [
          {
            fieldId: singleSelectField.id,
            operator: is.value,
            value: 'x',
          },
        ],
      };

      const linkField = await createField(table1.id, {
        name: 'convert link options filter',
        type: FieldType.Link,
        options: {
          relationship: Relationship.ManyMany,
          foreignTableId: table2.id,
          filter,
        },
      });

      expect((linkField.data.options as ILinkFieldOptions).filter).toEqual(filter);

      await convertField(table2.id, singleSelectField.id, {
        type: FieldType.MultipleSelect,
      });
      const currentLinkField = await getField(table1.id, linkField.data.id);

      expect((currentLinkField.options as ILinkFieldOptions).filter).toBeNull();
    });
  });
});