9e21e55db4
## Fix resolver schema leaking between `/metadata` and `/graphql` endpoints ### Summary - Patch `@nestjs/graphql` to support a `resolverSchemaScope` option that filters resolvers at both schema generation and runtime, preventing cross-endpoint leaking - Introduce `@CoreResolver()` and `@MetadataResolver()` decorators to explicitly scope each resolver to its endpoint - Move most resolvers (auth, billing, workspace, user, etc.) to the metadata schema where the frontend expects them; only workflow and timeline calendar/messaging resolvers remain on `/graphql` - Fix frontend `SSEQuerySubscribeEffect` to use the default (metadata) Apollo client instead of the core client ### Problem NestJS GraphQL's module-based resolver discovery traverses transitive imports, causing resolvers from `/metadata` modules to leak into the `/graphql` schema and vice versa. This made the schemas unpredictable and tightly coupled to module import order. ### Approach - Added `resolverSchemaScope` to `GqlModuleOptions` via a patch on `@nestjs/graphql`, filtering in both `filterResolvers()` (runtime binding) and `getAllCtors()` (schema generation) - Each resolver is explicitly decorated with `@CoreResolver()` or `@MetadataResolver()` - Organized decorator, constant, and type files under `graphql-config/` following project conventions Core GQL Schema: (see: no more fields!) <img width="827" height="894" alt="image" src="https://github.com/user-attachments/assets/668f3f0f-485e-43f0-92be-4345aeccacb6" /> Metadata GQL Schema (see no more getTimelineCalendarEventsFromCompany) <img width="827" height="894" alt="image" src="https://github.com/user-attachments/assets/443913db-e5fe-4161-b0e7-4a971cc80a71" />
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
import gql from 'graphql-tag';
|
|
import { uploadFilesFieldFileMutation } from 'test/integration/graphql/utils/upload-files-field-file-mutation.util';
|
|
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
|
|
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
|
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
|
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
|
import { makeMetadataAPIRequestWithFileUpload } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-file-upload.util';
|
|
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
|
import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
|
|
|
|
const deleteFileMutation = gql`
|
|
mutation DeleteFile($fileId: UUID!) {
|
|
deleteFile(fileId: $fileId) {
|
|
id
|
|
}
|
|
}
|
|
`;
|
|
|
|
describe('uploadFilesFieldFile', () => {
|
|
let createdObjectMetadataId: string;
|
|
let createdFieldMetadataId: string;
|
|
let uploadedFileId: string | null = null;
|
|
|
|
const getFieldMetadataUniversalIdentifier = async (
|
|
fieldMetadataId: string,
|
|
): Promise<string> => {
|
|
const result = await global.testDataSource.query(
|
|
'SELECT "universalIdentifier" FROM core."fieldMetadata" WHERE id = $1',
|
|
[fieldMetadataId],
|
|
);
|
|
|
|
return result[0].universalIdentifier;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
jest.useRealTimers();
|
|
|
|
const {
|
|
data: {
|
|
createOneObject: { id: objectMetadataId },
|
|
},
|
|
} = await createOneObjectMetadata({
|
|
input: {
|
|
nameSingular: 'uploadTestObject',
|
|
namePlural: 'uploadTestObjects',
|
|
labelSingular: 'Upload Test Object',
|
|
labelPlural: 'Upload Test Objects',
|
|
icon: 'IconFile',
|
|
},
|
|
});
|
|
|
|
createdObjectMetadataId = objectMetadataId;
|
|
|
|
const {
|
|
data: {
|
|
createOneField: { id: fieldMetadataId },
|
|
},
|
|
} = await createOneFieldMetadata({
|
|
input: {
|
|
name: 'filesField',
|
|
label: 'Files Field',
|
|
type: FieldMetadataType.FILES,
|
|
objectMetadataId: createdObjectMetadataId,
|
|
settings: { maxNumberOfValues: 5 },
|
|
},
|
|
gqlFields: `
|
|
id
|
|
name
|
|
label
|
|
type
|
|
`,
|
|
});
|
|
|
|
createdFieldMetadataId = fieldMetadataId;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (uploadedFileId) {
|
|
await makeMetadataAPIRequest({
|
|
query: deleteFileMutation,
|
|
variables: { fileId: uploadedFileId },
|
|
});
|
|
}
|
|
|
|
await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
idToUpdate: createdObjectMetadataId,
|
|
updatePayload: {
|
|
isActive: false,
|
|
},
|
|
},
|
|
});
|
|
await deleteOneObjectMetadata({
|
|
input: { idToDelete: createdObjectMetadataId },
|
|
});
|
|
|
|
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 makeMetadataAPIRequestWithFileUpload(
|
|
{
|
|
query: uploadFilesFieldFileMutation,
|
|
variables: {
|
|
file: null,
|
|
fieldMetadataId: createdFieldMetadataId,
|
|
},
|
|
},
|
|
{
|
|
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);
|
|
|
|
const fieldUniversalIdentifier = await getFieldMetadataUniversalIdentifier(
|
|
createdFieldMetadataId,
|
|
);
|
|
|
|
expect(fileResult.path).toContain(fieldUniversalIdentifier);
|
|
expect(fileResult.size).toBe(testFileContent.length);
|
|
expect(fileResult.createdAt).toBeDefined();
|
|
|
|
uploadedFileId = fileResult.id;
|
|
});
|
|
});
|