Skip to content

feat: support multiple file types for resume upload #2945

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jul 24, 2025
156 changes: 117 additions & 39 deletions __tests__/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ import { identifyUserPersonalizedDigest } from '../src/cio';
import type { GQLUser } from '../src/schema/users';
import { cancelSubscription } from '../src/common/paddle';
import { isPlusMember, SubscriptionCycles } from '../src/paddle';
import { CoresRole, StreakRestoreCoresPrice } from '../src/types';
import {
acceptedResumeExtensions,
CoresRole,
StreakRestoreCoresPrice,
} from '../src/types';
import {
UserTransaction,
UserTransactionProcessor,
Expand All @@ -134,27 +138,21 @@ import { SubscriptionProvider, SubscriptionStatus } from '../src/common/plus';
import * as njordCommon from '../src/common/njord';
import { createClient } from '@connectrpc/connect';
import { Credits, EntityType } from '@dailydotdev/schema';
import * as googleCloud from '../src/common/googleCloud';
import { RESUMES_BUCKET_NAME } from '../src/common/googleCloud';
import { fileTypeFromBuffer } from './setup';
import { Bucket } from '@google-cloud/storage';

jest.mock('../src/common/geo', () => ({
...(jest.requireActual('../src/common/geo') as Record<string, unknown>),
getGeo: jest.fn(),
}));

const uploadFileFromBuffer = jest.fn();
const uploadResumeFromBuffer = jest.fn();
const deleteResumeByUserId = jest.fn();
jest.mock('../src/common/googleCloud', () => ({
...(jest.requireActual('../src/common/googleCloud') as Record<
string,
unknown
>),
uploadFileFromBuffer: (...args: unknown[]) => uploadFileFromBuffer(...args),
uploadResumeFromBuffer: (...args: unknown[]) =>
uploadResumeFromBuffer(...args),
deleteResumeByUserId: (...args: unknown[]) => deleteResumeByUserId(...args),
}));
const uploadResumeFromBuffer = jest.spyOn(
googleCloud,
'uploadResumeFromBuffer',
);
const deleteFileFromBucket = jest.spyOn(googleCloud, 'deleteFileFromBucket');

let con: DataSource;
let app: FastifyInstance;
Expand Down Expand Up @@ -4382,32 +4380,42 @@ describe('mutation deleteUser', () => {
expect(deletedUser).not.toBeNull();
});

it('should delete user resume if it exists', async () => {
loggedUser = '1';

// Mock that the resume file exists
deleteResumeByUserId.mockResolvedValue(true);
describe('deleting user resume', () => {
it('should delete user resume if it exists', async () => {
loggedUser = '1';

await client.mutate(MUTATION);
await client.mutate(MUTATION);

// Verify the resume was deleted
expect(deleteResumeByUserId).toHaveBeenCalledWith('1');
});
// Verify we requested delete action for every extension supported
acceptedResumeExtensions.forEach((ext, index) => {
expect(deleteFileFromBucket).toHaveBeenNthCalledWith(
index + 1,
expect.any(Bucket),
`${loggedUser}.${ext}`,
);
});
});

it('should handle case when user has no resume', async () => {
loggedUser = '1';
it('should handle case when user has no resume', async () => {
loggedUser = '1';

// Mock that the resume file doesn't exist
deleteResumeByUserId.mockResolvedValue(false);
// Mock that the resume file doesn't exist

await client.mutate(MUTATION);
await client.mutate(MUTATION);

// Verify the function was called but no error was thrown
expect(deleteResumeByUserId).toHaveBeenCalledWith('1');
// Verify the function was called but no error was thrown
acceptedResumeExtensions.forEach((ext, index) => {
expect(deleteFileFromBucket).toHaveBeenNthCalledWith(
index + 1,
expect.any(Bucket),
`${loggedUser}.${ext}`,
);
});

// User should still be deleted
const userOne = await con.getRepository(User).findOneBy({ id: '1' });
expect(userOne).toEqual(null);
// User should still be deleted
const userOne = await con.getRepository(User).findOneBy({ id: '1' });
expect(userOne).toEqual(null);
});
});
});

Expand Down Expand Up @@ -6785,7 +6793,7 @@ describe('mutation uploadResume', () => {
});

it('should require authentication', async () => {
loggedUser = null;
loggedUser = '';

const res = await authorizeRequest(
request(app.server)
Expand All @@ -6807,7 +6815,7 @@ describe('mutation uploadResume', () => {
expect(body.errors[0].extensions.code).toEqual('UNAUTHENTICATED');
});

it('should upload resume successfully', async () => {
it('should upload pdf resume successfully', async () => {
loggedUser = '1';

// mock the file-type check to allow PDF files
Expand Down Expand Up @@ -6848,6 +6856,47 @@ describe('mutation uploadResume', () => {
);
});

it('should upload docx resume successfully', async () => {
loggedUser = '1';

// mock the file-type check to allow PDF files
fileTypeFromBuffer.mockResolvedValue({
ext: 'docx',
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});

// Mock the upload function to return a URL
uploadResumeFromBuffer.mockResolvedValue(
`https://storage.cloud.google.com/${RESUMES_BUCKET_NAME}/1.docx`,
);

// Execute the mutation with a file upload
const res = await authorizeRequest(
request(app.server)
.post('/graphql')
.field(
'operations',
JSON.stringify({
query: MUTATION,
variables: { resume: null },
}),
)
.field('map', JSON.stringify({ '0': ['variables.resume'] }))
.attach('0', './__tests__/fixture/screen.pdf', 'sample.docx'),
loggedUser,
).expect(200);

// Verify the response
const body = res.body;
expect(body.errors).toBeFalsy();

// Verify the mocks were called correctly
expect(uploadResumeFromBuffer).toHaveBeenCalledWith(
`${loggedUser}.docx`,
expect.any(Object),
);
});

it('should throw error when file is missing', async () => {
loggedUser = '1';

Expand All @@ -6860,7 +6909,7 @@ describe('mutation uploadResume', () => {
);
});

it('should throw error when file extension is not PDF', async () => {
it('should throw error when file extension is not supported', async () => {
loggedUser = '1';

const res = await authorizeRequest(
Expand All @@ -6880,10 +6929,10 @@ describe('mutation uploadResume', () => {

const body = res.body;
expect(body.errors).toBeTruthy();
expect(body.errors[0].message).toEqual('Extension must be .pdf');
expect(body.errors[0].message).toEqual('File extension not supported');
});

it('should throw error when file is not actually a PDF', async () => {
it('should throw error when file mime is not supported', async () => {
loggedUser = '1';

// mock the file-type check to allow PDF files
Expand All @@ -6910,6 +6959,35 @@ describe('mutation uploadResume', () => {

const body = res.body;
expect(body.errors).toBeTruthy();
expect(body.errors[0].message).toEqual('File is not a PDF');
expect(body.errors[0].message).toEqual('File type not supported');
});

it("should throw error when file extension doesn't match the mime type", async () => {
loggedUser = '1';

// mock the file-type check to allow PDF files
fileTypeFromBuffer.mockResolvedValue({
ext: 'pdf',
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Incorrect mime type for a PDF
});

const res = await authorizeRequest(
request(app.server)
.post('/graphql')
.field(
'operations',
JSON.stringify({
query: MUTATION,
variables: { resume: null },
}),
)
.field('map', JSON.stringify({ '0': ['variables.resume'] }))
.attach('0', './__tests__/fixture/happy_card.png', 'fake.pdf'),
loggedUser,
).expect(200);

const body = res.body;
expect(body.errors).toBeTruthy();
expect(body.errors[0].message).toEqual('File type not supported');
});
});
38 changes: 31 additions & 7 deletions src/common/googleCloud.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DownloadOptions, Storage } from '@google-cloud/storage';
import { PropsParameters } from '../types';
import { Bucket, DownloadOptions, Storage } from '@google-cloud/storage';
import { acceptedResumeExtensions, PropsParameters } from '../types';
import path from 'path';
import { BigQuery } from '@google-cloud/bigquery';
import { Query } from '@google-cloud/bigquery/build/src/bigquery';
Expand Down Expand Up @@ -64,10 +64,30 @@ export const uploadResumeFromBuffer = async (
});
};

export const deleteFileFromBucket = async (
bucket: Bucket,
fileName: string,
) => {
const file = bucket.file(fileName);

try {
const [exists] = await file.exists();
if (exists) {
await file.delete();
return true;
}
} catch (e) {
logger.error(
{ bucketName: bucket.name, fileName, error: e },
'Failed to delete file from bucket',
);
}
return false;
};

export const deleteResumeByUserId = async (
userId: string,
): Promise<boolean> => {
const fileName = `${userId}.pdf`;
const bucketName = RESUMES_BUCKET_NAME;

if (!userId?.trim()) {
Expand All @@ -78,14 +98,18 @@ export const deleteResumeByUserId = async (
try {
const storage = new Storage();
const bucket = storage.bucket(bucketName);
const file = bucket.file(fileName);

await file.delete();
await Promise.all(
// delete all possible accepted {id}.{ext} files uploaded by the user
acceptedResumeExtensions.map((ext) =>
deleteFileFromBucket(bucket, `${userId}.${ext}`),
),
);

logger.info(
{
userId,
fileName,
acceptedResumeExtensions,
bucketName,
},
'deleted user resume',
Expand All @@ -96,7 +120,7 @@ export const deleteResumeByUserId = async (
logger.error(
{
userId,
fileName,
acceptedResumeExtensions,
bucketName,
error,
},
Expand Down
19 changes: 13 additions & 6 deletions src/schema/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { randomInt, randomUUID } from 'crypto';
import { ArrayContains, DataSource, In, IsNull, QueryRunner } from 'typeorm';
import { DisallowHandle } from '../entity/DisallowHandle';
import {
acceptedResumeFileTypes,
ContentLanguage,
CoresRole,
StreakRestoreCoresPrice,
Expand Down Expand Up @@ -2389,22 +2390,28 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
const upload = await resume;

// Validate file extension
const extension = upload.filename?.split('.')?.pop()?.toLowerCase();
if (extension !== 'pdf') {
throw new ValidationError('Extension must be .pdf');
const extension: string | undefined = upload.filename
?.split('.')
?.pop()
?.toLowerCase();
const supportedFileType = acceptedResumeFileTypes.find(
(type) => type.ext === extension,
);
if (!supportedFileType) {
throw new ValidationError('File extension not supported');
}

// Buffer the stream
const buffer = await getBufferFromStream(upload.createReadStream());

// Validate MIME type using buffer
const fileType = await fileTypeFromBuffer(buffer);
if (fileType?.mime !== 'application/pdf') {
throw new ValidationError('File is not a PDF');
if (supportedFileType.mime !== fileType?.mime) {
throw new ValidationError('File type not supported');
}

// Actual upload using buffer as a stream
const filename = `${ctx.userId}.pdf`;
const filename = `${ctx.userId}.${extension}`;
await uploadResumeFromBuffer(filename, buffer);

return { _: true };
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,15 @@ export enum StreakRestoreCoresPrice {
First = 0,
Regular = 100,
}

export const acceptedResumeFileTypes: Array<Record<'mime' | 'ext', string>> = [
{ mime: 'application/pdf', ext: 'pdf' },
{
mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
ext: 'docx',
},
] as const;
export const acceptedResumeExtensions = [
'pdf',
'docx',
] as const satisfies Array<(typeof acceptedResumeFileTypes)[number]['ext']>;
Loading