Files v2 - Add new workspace field file upload resolver (#17325)

This commit is contained in:
Etienne
2026-01-22 14:15:52 +01:00
committed by GitHub
parent 96bdae4582
commit 0459f25dec
18 changed files with 505 additions and 64 deletions
@@ -1600,11 +1600,13 @@ export enum FileFolder {
BuiltFrontComponent = 'BuiltFrontComponent',
BuiltFunction = 'BuiltFunction',
File = 'File',
FilesField = 'FilesField',
PersonPicture = 'PersonPicture',
ProfilePicture = 'ProfilePicture',
ServerlessFunction = 'ServerlessFunction',
ServerlessFunctionToDelete = 'ServerlessFunctionToDelete',
Source = 'Source',
TemporaryFilesField = 'TemporaryFilesField',
WorkspaceLogo = 'WorkspaceLogo'
}
@@ -2166,7 +2168,9 @@ export type Mutation = {
updateWorkspaceFeatureFlag: Scalars['Boolean'];
updateWorkspaceMemberRole: WorkspaceMember;
uploadApplicationFile: File;
/** @deprecated Use uploadFilesFieldFile instead */
uploadFile: SignedFile;
uploadFilesFieldFile: File;
uploadImage: SignedFile;
uploadWorkspaceLogo: SignedFile;
uploadWorkspaceMemberProfilePicture: SignedFile;
@@ -3110,6 +3114,11 @@ export type MutationUploadFileArgs = {
};
export type MutationUploadFilesFieldFileArgs = {
file: Scalars['Upload'];
};
export type MutationUploadImageArgs = {
file: Scalars['Upload'];
fileFolder?: InputMaybe<FileFolder>;
@@ -1567,11 +1567,13 @@ export enum FileFolder {
BuiltFrontComponent = 'BuiltFrontComponent',
BuiltFunction = 'BuiltFunction',
File = 'File',
FilesField = 'FilesField',
PersonPicture = 'PersonPicture',
ProfilePicture = 'ProfilePicture',
ServerlessFunction = 'ServerlessFunction',
ServerlessFunctionToDelete = 'ServerlessFunctionToDelete',
Source = 'Source',
TemporaryFilesField = 'TemporaryFilesField',
WorkspaceLogo = 'WorkspaceLogo'
}
@@ -2119,7 +2121,9 @@ export type Mutation = {
updateWorkspaceFeatureFlag: Scalars['Boolean'];
updateWorkspaceMemberRole: WorkspaceMember;
uploadApplicationFile: File;
/** @deprecated Use uploadFilesFieldFile instead */
uploadFile: SignedFile;
uploadFilesFieldFile: File;
uploadImage: SignedFile;
uploadWorkspaceLogo: SignedFile;
uploadWorkspaceMemberProfilePicture: SignedFile;
@@ -2992,6 +2996,11 @@ export type MutationUploadFileArgs = {
};
export type MutationUploadFilesFieldFileArgs = {
file: Scalars['Upload'];
};
export type MutationUploadImageArgs = {
file: Scalars['Upload'];
fileFolder?: InputMaybe<FileFolder>;
@@ -1,9 +1,11 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Readable } from 'stream';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
describe('FileStorageService', () => {
let service: FileStorageService;
@@ -13,6 +15,10 @@ describe('FileStorageService', () => {
getCurrentDriver: jest.fn(),
};
const mockFileRepository = {
save: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -21,6 +27,10 @@ describe('FileStorageService', () => {
provide: FileStorageDriverFactory,
useValue: mockFileStorageDriverFactory,
},
{
provide: getRepositoryToken(FileEntity),
useValue: mockFileRepository,
},
],
}).compile();
@@ -48,6 +58,9 @@ describe('FileStorageService', () => {
copy: jest.fn(),
download: jest.fn(),
checkFileExists: jest.fn(),
checkFolderExists: jest.fn(),
writeFolder: jest.fn(),
readFolder: jest.fn(),
};
mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver);
@@ -67,7 +80,11 @@ describe('FileStorageService', () => {
await service.write(writeParams);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.write).toHaveBeenCalledWith(writeParams);
expect(mockDriver.write).toHaveBeenCalledWith({
filePath: 'documents/test.txt',
sourceFile: writeParams.file,
mimeType: 'text/plain',
});
});
it('should handle write errors', async () => {
@@ -86,7 +103,6 @@ describe('FileStorageService', () => {
'Write failed',
);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.write).toHaveBeenCalledWith(writeParams);
});
});
@@ -104,7 +120,9 @@ describe('FileStorageService', () => {
const result = await service.read(readParams);
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.read).toHaveBeenCalledWith(readParams);
expect(mockDriver.read).toHaveBeenCalledWith({
filePath: 'documents/test.txt',
});
expect(result).toBe(mockStream);
});
@@ -120,7 +138,6 @@ describe('FileStorageService', () => {
await expect(service.read(readParams)).rejects.toThrow('Read failed');
expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled();
expect(mockDriver.read).toHaveBeenCalledWith(readParams);
});
});
@@ -4,12 +4,11 @@ import { type Sources } from 'twenty-shared/types';
export interface StorageDriver {
delete(params: { folderPath: string; filename?: string }): Promise<void>;
read(params: { folderPath: string; filename: string }): Promise<Readable>;
read(params: { filePath: string }): Promise<Readable>;
readFolder(folderPath: string): Promise<Sources>;
write(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
filePath: string;
sourceFile: Buffer | Uint8Array | string;
mimeType: string | undefined;
}): Promise<void>;
writeFolder(sources: Sources, folderPath: string): Promise<void>;
@@ -28,21 +28,16 @@ export class LocalDriver implements StorageDriver {
}
async write(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
filePath: string;
sourceFile: Buffer | Uint8Array | string;
mimeType: string | undefined;
}): Promise<void> {
const filePath = join(
`${this.options.storagePath}/`,
params.folder,
params.name,
);
const filePath = `${this.options.storagePath}/${params.filePath}`;
const folderPath = dirname(filePath);
await this.createFolder(folderPath);
await fs.writeFile(filePath, params.file);
await fs.writeFile(filePath, params.sourceFile);
}
async writeFolder(sources: Sources, folderPath: string) {
@@ -52,10 +47,9 @@ export class LocalDriver implements StorageDriver {
continue;
}
await this.write({
file: sources[key],
name: key,
filePath: join(folderPath, key),
sourceFile: sources[key],
mimeType: undefined,
folder: folderPath,
});
}
}
@@ -73,15 +67,8 @@ export class LocalDriver implements StorageDriver {
await fs.rm(filePath, { recursive: true });
}
async read(params: {
folderPath: string;
filename: string;
}): Promise<Readable> {
const joinedPath = join(
`${this.options.storagePath}/`,
params.folderPath,
params.filename,
);
async read(params: { filePath: string }): Promise<Readable> {
const joinedPath = join(`${this.options.storagePath}/`, params.filePath);
let filePath: string;
try {
@@ -20,8 +20,8 @@ import {
S3,
type S3ClientConfig,
} from '@aws-sdk/client-s3';
import { isDefined } from 'twenty-shared/utils';
import { isObject } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
import {
@@ -61,14 +61,13 @@ export class S3Driver implements StorageDriver {
}
async write(params: {
file: Buffer | Uint8Array | string;
name: string;
folder: string;
filePath: string;
sourceFile: Buffer | Uint8Array | string;
mimeType: string | undefined;
}): Promise<void> {
const command = new PutObjectCommand({
Key: `${params.folder}/${params.name}`,
Body: params.file,
Key: params.filePath,
Body: params.sourceFile,
ContentType: params.mimeType,
Bucket: this.bucketName,
});
@@ -83,10 +82,9 @@ export class S3Driver implements StorageDriver {
continue;
}
await this.write({
file: sources[key],
name: key,
filePath: `${folderPath}/${key}`,
sourceFile: sources[key],
mimeType: undefined,
folder: folderPath,
});
}
}
@@ -169,12 +167,9 @@ export class S3Driver implements StorageDriver {
}
}
async read(params: {
folderPath: string;
filename: string;
}): Promise<Readable> {
async read(params: { filePath: string }): Promise<Readable> {
const command = new GetObjectCommand({
Key: `${params.folderPath}/${params.filename}`,
Key: params.filePath,
Bucket: this.bucketName,
});
@@ -222,7 +217,7 @@ export class S3Driver implements StorageDriver {
const { fromFolderPath, filename } = folderAndFilePaths;
const fileContent = await readFileContent(
await this.read({ folderPath: fromFolderPath, filename }),
await this.read({ filePath: `${fromFolderPath}/${filename}` }),
);
const formattedObjectKey = object.Key.replace(
@@ -441,8 +436,7 @@ export class S3Driver implements StorageDriver {
await mkdir(dir, { recursive: true });
const fileStream = await this.read({
folderPath: params.from.folderPath,
filename: params.from.filename,
filePath: `${params.from.folderPath}/${params.from.filename}`,
});
const toPath = join(
@@ -1,7 +1,9 @@
import { type DynamicModule, Global } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Global()
@@ -9,7 +11,7 @@ export class FileStorageModule {
static forRoot(): DynamicModule {
return {
module: FileStorageModule,
imports: [TwentyConfigModule],
imports: [TwentyConfigModule, TypeOrmModule.forFeature([FileEntity])],
providers: [FileStorageDriverFactory, FileStorageService],
exports: [FileStorageService],
};
@@ -1,28 +1,111 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type Readable } from 'stream';
import { Sources } from 'twenty-shared/types';
import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface';
import { FileFolder, Sources } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@Injectable()
export class FileStorageService implements StorageDriver {
//TODO: Implement storage driver interface when removing v1
//export class FileStorageService implements StorageDriver {
export class FileStorageService {
constructor(
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
) {}
/**
* @deprecated Use write_v2 instead
*/
write(params: {
file: string | Buffer | Uint8Array;
name: string;
folder: string;
mimeType: string | undefined;
}): Promise<void> {
const { file, name, folder, mimeType } = params;
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.write(params);
return driver.write({
filePath: `${folder}/${name}`,
sourceFile: file,
mimeType,
});
}
async write_v2({
sourceFile,
destinationPath,
mimeType,
fileFolder,
applicationId,
workspaceId,
fileId,
}: {
sourceFile: string | Buffer | Uint8Array;
destinationPath: string;
mimeType: string | undefined;
fileFolder: FileFolder;
applicationId: string;
workspaceId: string;
fileId?: string;
}): Promise<FileEntity> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const driverParams = {
filePath: `${workspaceId}/${applicationId}/${fileFolder}/${destinationPath}`,
mimeType,
sourceFile,
};
await driver.write(driverParams);
const fileEntity = await this.fileRepository.save({
path: `${fileFolder}/${destinationPath}`,
workspaceId,
applicationId,
id: fileId,
size:
typeof sourceFile === 'string'
? Buffer.byteLength(sourceFile)
: sourceFile.length,
});
return fileEntity;
}
/**
* @deprecated Use read_v2 instead
*/
read(params: { folderPath: string; filename: string }): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const { folderPath, filename } = params;
return driver.read({ filePath: `${folderPath}/${filename}` });
}
read_v2({
destinationPath,
fileFolder,
applicationId,
workspaceId,
}: {
destinationPath: string;
fileFolder: FileFolder;
applicationId: string;
workspaceId: string;
}): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const folderPath = `${workspaceId}/${applicationId}/${fileFolder}/${destinationPath}`;
return driver.read({ filePath: folderPath });
}
writeFolder(sources: Sources, folderPath: string): Promise<void> {
@@ -31,12 +114,6 @@ export class FileStorageService implements StorageDriver {
return driver.writeFolder(sources, folderPath);
}
read(params: { folderPath: string; filename: string }): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
return driver.read(params);
}
readFolder(folderPath: string): Promise<Sources> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
@@ -1,5 +1,3 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
@@ -17,7 +15,6 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity('file')
@ObjectType('File')
@Index('IDX_FILE_WORKSPACE_ID', ['workspaceId'])
export class FileEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
@@ -7,6 +7,7 @@ import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
@@ -24,7 +25,9 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
export class FileUploadResolver {
constructor(private readonly fileUploadService: FileUploadService) {}
@Mutation(() => SignedFileDTO)
@Mutation(() => SignedFileDTO, {
deprecationReason: 'Use uploadFilesFieldFile instead',
})
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@@ -51,6 +54,28 @@ export class FileUploadResolver {
return files[0];
}
@Mutation(() => FileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFilesFieldFile(
@AuthWorkspace()
{ id: workspaceId, workspaceCustomApplicationId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<FileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileEntity = await this.fileUploadService.uploadFilesFieldFile({
file: buffer,
filename,
declaredMimeType: mimetype,
workspaceId,
applicationId: workspaceCustomApplicationId,
});
return fileEntity;
}
@Mutation(() => SignedFileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadImage(
@@ -4,13 +4,15 @@ import { Injectable } from '@nestjs/common';
import DOMPurify from 'dompurify';
import FileType from 'file-type';
import sharp from 'sharp';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { type FileFolder } from 'twenty-shared/types';
import { settings } from 'src/engine/constants/settings';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { getCropSize, getImageBufferFromUrl } from 'src/utils/image';
export type SignedFile = { path: string; token: string };
@@ -68,6 +70,9 @@ export class FileUploadService {
return file;
}
/**
* @deprecated Use uploadWorkspaceRecordFile if uploading workspace records-scoped files. Or create your dedicated upload file service.
*/
async uploadFile({
file,
filename,
@@ -206,4 +211,39 @@ export class FileUploadService {
private getWorkspaceFolderName(workspaceId: string, fileFolder: FileFolder) {
return `workspace-${workspaceId}/${fileFolder}`;
}
async uploadFilesFieldFile({
file,
filename,
declaredMimeType,
workspaceId,
applicationId,
}: {
file: Buffer;
filename: string;
declaredMimeType: string | undefined;
workspaceId: string;
applicationId: string;
}): Promise<FileEntity> {
const { mimeType, ext } = await extractFileInfo({
file,
declaredMimeType,
filename,
});
const sanitizedFile = this._sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${ext ? `.${ext}` : ''}`;
return await this.fileStorage.write_v2({
sourceFile: sanitizedFile,
destinationPath: name,
mimeType,
fileFolder: FileFolder.FilesField,
applicationId,
workspaceId,
fileId,
});
}
}
@@ -1,7 +1,7 @@
import { registerEnumType } from '@nestjs/graphql';
import { type KebabCase } from 'type-fest';
import { FileFolder } from 'twenty-shared/types';
import { type KebabCase } from 'type-fest';
registerEnumType(FileFolder, {
name: 'FileFolder',
@@ -48,6 +48,12 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.Source]: {
ignoreExpirationToken: false,
},
[FileFolder.FilesField]: {
ignoreExpirationToken: false,
},
[FileFolder.TemporaryFilesField]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -0,0 +1,108 @@
import FileType from 'file-type';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
jest.mock('file-type', () => ({
fromBuffer: jest.fn(),
}));
describe('extractFileInfo', () => {
const mockBuffer = Buffer.from('test content');
beforeEach(() => {
jest.clearAllMocks();
});
it('should use detected file type when available', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue({
mime: 'image/png',
ext: 'png',
});
const result = await extractFileInfo({
file: mockBuffer,
declaredMimeType: 'text/plain',
filename: 'test.txt',
});
expect(result).toEqual({
mimeType: 'image/png',
ext: 'png',
});
});
it('should fall back to declared values when file type is not detected', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
const result = await extractFileInfo({
file: mockBuffer,
declaredMimeType: 'text/plain',
filename: 'test.txt',
});
expect(result).toEqual({
mimeType: 'text/plain',
ext: 'txt',
});
});
it('should handle missing declared mime type when file type is detected', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue({
mime: 'application/pdf',
ext: 'pdf',
});
const result = await extractFileInfo({
file: mockBuffer,
declaredMimeType: undefined,
filename: 'document',
});
expect(result).toEqual({
mimeType: 'application/pdf',
ext: 'pdf',
});
});
it('should handle both mime type and extension being undefined', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
const result = await extractFileInfo({
file: mockBuffer,
declaredMimeType: undefined,
filename: 'file-without-extension',
});
expect(result).toEqual({
mimeType: undefined,
ext: '',
});
});
it('should handle filenames with multiple dots', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
const result = await extractFileInfo({
file: mockBuffer,
declaredMimeType: 'application/gzip',
filename: 'archive.tar.gz',
});
expect(result).toEqual({
mimeType: 'application/gzip',
ext: 'gz',
});
});
it('should call FileType.fromBuffer with the provided buffer', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
await extractFileInfo({
file: mockBuffer,
declaredMimeType: 'image/png',
filename: 'image.png',
});
expect(FileType.fromBuffer).toHaveBeenCalledWith(mockBuffer);
});
});
@@ -0,0 +1,26 @@
import FileType from 'file-type';
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
export const extractFileInfo = async ({
file,
declaredMimeType,
filename,
}: {
file: Buffer;
declaredMimeType: string | undefined;
filename: string;
}) => {
const { ext: declaredExt } = buildFileInfo(filename);
const detectedFileType = await FileType.fromBuffer(file);
const mimeType = detectedFileType?.mime ?? declaredMimeType;
const ext = detectedFileType?.ext ?? declaredExt;
return {
mimeType,
ext,
};
};
@@ -0,0 +1,77 @@
import gql from 'graphql-tag';
import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { FileFolder } from 'twenty-shared/types';
const uploadFilesFieldFileMutation = gql`
mutation uploadFilesFieldFile($file: Upload!) {
uploadFilesFieldFile(file: $file) {
id
path
size
createdAt
}
}
`;
const deleteFileMutation = gql`
mutation DeleteFile($fileId: UUID!) {
deleteFile(fileId: $fileId) {
id
}
}
`;
describe('uploadFilesFieldFile', () => {
let uploadedFileId: string | null = null;
beforeAll(() => {
jest.useRealTimers();
});
afterAll(async () => {
if (uploadedFileId) {
await makeGraphqlAPIRequest({
query: deleteFileMutation,
variables: { fileId: uploadedFileId },
});
}
jest.useFakeTimers();
});
it('should upload a file and return file metadata', async () => {
const testFileContent = 'Hello, this is a test file content';
const testFileName = 'test-file.txt';
const testMimeType = 'text/plain';
const response = await makeGraphqlAPIRequestWithFileUpload(
{
query: uploadFilesFieldFileMutation,
variables: { file: null },
},
{
field: 'file',
buffer: Buffer.from(testFileContent),
filename: testFileName,
contentType: testMimeType,
},
);
expect(response.status).toBe(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const fileResult = response.body.data.uploadFilesFieldFile;
expect(fileResult).toBeDefined();
expect(fileResult.id).toBeDefined();
expect(typeof fileResult.id).toBe('string');
expect(fileResult.path).toBeDefined();
expect(typeof fileResult.path).toBe('string');
expect(fileResult.path).toContain(FileFolder.FilesField);
expect(fileResult.size).toBe(testFileContent.length);
expect(fileResult.createdAt).toBeDefined();
uploadedFileId = fileResult.id;
});
});
@@ -0,0 +1,46 @@
import { type ASTNode, print } from 'graphql';
import request from 'supertest';
import { isDefined } from 'twenty-shared/utils';
type FileUploadOperation = {
query: ASTNode;
variables?: Record<string, unknown>;
};
type FileAttachment = {
field: string;
buffer: Buffer;
filename: string;
contentType: string;
};
export const makeGraphqlAPIRequestWithFileUpload = (
graphqlOperation: FileUploadOperation,
fileAttachment: FileAttachment,
token: string | undefined = APPLE_JANE_ADMIN_ACCESS_TOKEN,
) => {
const client = request(`http://localhost:${APP_PORT}`);
const clientInstance = client.post('/graphql');
if (isDefined(token)) {
clientInstance.set('Authorization', `Bearer ${token}`);
}
return clientInstance
.field(
'operations',
JSON.stringify({
query: print(graphqlOperation.query),
variables: graphqlOperation.variables || {},
}),
)
.field(
'map',
JSON.stringify({ '0': [`variables.${fileAttachment.field}`] }),
)
.attach('0', fileAttachment.buffer, {
filename: fileAttachment.filename,
contentType: fileAttachment.contentType,
});
};
@@ -6,8 +6,12 @@ import {
type TestingModuleBuilder,
} from '@nestjs/testing';
import bytes from 'bytes';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from 'src/app.module';
import { CommandModule } from 'src/command/command.module';
import { settings } from 'src/engine/constants/settings';
import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/constants/captcha-driver.constants';
@@ -75,6 +79,22 @@ export const createApp = async (
cors: true,
});
app.use(
'/graphql',
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize),
maxFiles: 10,
}),
);
app.use(
'/metadata',
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize),
maxFiles: 10,
}),
);
if (config.appInitHook) {
await config.appInitHook(app);
}
@@ -11,4 +11,6 @@ export enum FileFolder {
BuiltFrontComponent = 'built-front-component',
Asset = 'asset',
Source = 'source',
TemporaryFilesField = 'temporary-files-field',
FilesField = 'files-field',
}