Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
createGoogleDriveDownloadLink,
createGoogleDriveExportLink,
NATIVE_GOOGLE_APPS_EXPORT_MIMES,
} from './createGoogleDriveDownloadLink';
import { GoogleDriveFile } from '../../../data_layer/GoogleDriveRepository';

const baseFile: GoogleDriveFile = {
id: 'file123',
name: 'My Doc',
mimeType: 'application/vnd.google-apps.document',
iconUrl: '',
url: '',
sizeBytes: 0,
embedUrl: '',
description: '',
driveSuccess: true,
isShared: false,
lastEditedUtc: 0,
rotation: 0,
rotationDegree: 0,
serviceId: '',
type: 'document',
};

describe('createGoogleDriveDownloadLink', () => {
it('returns an alt=media download URL for a binary file', () => {
const file = { ...baseFile, mimeType: 'application/pdf' };
expect(createGoogleDriveDownloadLink(file)).toBe(
'https://www.googleapis.com/drive/v3/files/file123?alt=media'
);
});
});

describe('createGoogleDriveExportLink', () => {
it('returns an export URL with the given mime type', () => {
expect(createGoogleDriveExportLink(baseFile, 'text/html')).toBe(
'https://www.googleapis.com/drive/v3/files/file123/export?mimeType=text%2Fhtml'
);
});

it('encodes the mime type correctly for CSV', () => {
expect(createGoogleDriveExportLink(baseFile, 'text/csv')).toBe(
'https://www.googleapis.com/drive/v3/files/file123/export?mimeType=text%2Fcsv'
);
});

it('encodes the mime type correctly for PDF', () => {
expect(
createGoogleDriveExportLink(baseFile, 'application/pdf')
).toBe(
'https://www.googleapis.com/drive/v3/files/file123/export?mimeType=application%2Fpdf'
);
});
});

describe('NATIVE_GOOGLE_APPS_EXPORT_MIMES', () => {
it('maps google-apps.document to text/html with .html extension', () => {
expect(NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.document']).toEqual({
exportMime: 'text/html',
extension: '.html',
});
});

it('maps google-apps.spreadsheet to text/csv with .csv extension', () => {
expect(NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.spreadsheet']).toEqual({
exportMime: 'text/csv',
extension: '.csv',
});
});

it('maps google-apps.presentation to application/pdf with .pdf extension', () => {
expect(NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.presentation']).toEqual({
exportMime: 'application/pdf',
extension: '.pdf',
});
});

it('does not include google-apps.folder', () => {
expect(
NATIVE_GOOGLE_APPS_EXPORT_MIMES['application/vnd.google-apps.folder']
).toBeUndefined();
});
});
35 changes: 30 additions & 5 deletions src/controllers/Upload/helpers/createGoogleDriveDownloadLink.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
import { GoogleDriveFile } from '../../../data_layer/GoogleDriveRepository';

/**
* Create a download link for a Google Drive file. The default URL is just a preview link and request user interaction
* to download the file. This link will directly download the file.
* @param file
*/
export const NATIVE_GOOGLE_APPS_EXPORT_MIMES: Record<
string,
{ exportMime: string; extension: string }
> = {
'application/vnd.google-apps.document': {
exportMime: 'text/html',
extension: '.html',
},
'application/vnd.google-apps.spreadsheet': {
exportMime: 'text/csv',
extension: '.csv',
},
'application/vnd.google-apps.presentation': {
exportMime: 'application/pdf',
extension: '.pdf',
},
};

export function createGoogleDriveDownloadLink(file: GoogleDriveFile) {
return 'https://www.googleapis.com/drive/v3/files/' + file.id + '?alt=media';
}

export function createGoogleDriveExportLink(
file: GoogleDriveFile,
exportMime: string
) {
return (
'https://www.googleapis.com/drive/v3/files/' +
file.id +
'/export?mimeType=' +
encodeURIComponent(exportMime)
);
}
193 changes: 193 additions & 0 deletions src/controllers/Upload/helpers/handleGoogleDrive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import type express from 'express';

jest.mock('../../../services/observability/instrumentedAxios');

jest.mock('../../../data_layer', () => ({
getDatabase: jest.fn().mockReturnValue({}),
}));

jest.mock('../../../data_layer/GoogleDriveRepository', () => ({
GoogleDriveRepository: jest.fn().mockImplementation(() => ({
saveFiles: jest.fn().mockResolvedValue(undefined),
})),
}));

jest.mock('../../../lib/User/getOwner', () => ({
getOwner: jest.fn().mockReturnValue(null),
}));

jest.mock('../../../lib/isPaying', () => ({
isPaying: jest.fn().mockReturnValue(false),
}));

jest.mock('../../../lib/integrations/stripe', () => ({
getStripe: jest.fn().mockReturnValue({
customers: { retrieve: jest.fn() },
}),
updateStoreSubscription: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../../services/SubscriptionService', () => ({
__esModule: true,
default: { findActiveStripeSubscriptions: jest.fn() },
}));

jest.mock('../../../services/EmailService/EmailService', () => ({
getDefaultEmailService: jest.fn().mockReturnValue({}),
}));

jest.mock('../../../data_layer/UsersRepository');
jest.mock('../../../services/UsersService');

import instrumentedAxios from '../../../services/observability/instrumentedAxios';
import { handleGoogleDrive } from './handleGoogleDrive';

const mockedAxios = instrumentedAxios as jest.Mocked<typeof instrumentedAxios>;

function makeReq(
files: object[],
googleDriveAuth: string | undefined = 'valid-token'
): express.Request {
return {
body: {
files: JSON.stringify(files),
googleDriveAuth,
},
} as unknown as express.Request;
}

interface FakeRes {
statusCode: number;
sentBody: string;
status: (code: number) => FakeRes;
send: (body: string) => FakeRes;
locals: Record<string, unknown>;
}

function makeRes(): FakeRes {
const res: FakeRes = {
statusCode: 200,
sentBody: '',
status(code: number) {
res.statusCode = code;
return res;
},
send(body: string) {
res.sentBody = body;
return res;
},
locals: {},
};
return res;
}

const basePdfFile = {
id: 'pdf-file-id',
name: 'lecture.pdf',
mimeType: 'application/pdf',
iconUrl: '',
url: '',
sizeBytes: 1024,
embedUrl: '',
description: '',
driveSuccess: true,
isShared: false,
lastEditedUtc: 0,
serviceId: '',
type: 'document',
};

const baseDocFile = {
...basePdfFile,
id: 'doc-file-id',
name: 'lecture notes',
mimeType: 'application/vnd.google-apps.document',
};

const baseSheetFile = {
...basePdfFile,
id: 'sheet-file-id',
name: 'vocab list',
mimeType: 'application/vnd.google-apps.spreadsheet',
};

const baseSlidesFile = {
...basePdfFile,
id: 'slides-file-id',
name: 'bio lecture',
mimeType: 'application/vnd.google-apps.presentation',
};

describe('handleGoogleDrive — native Google Apps mime types', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedAxios.get.mockResolvedValue({ data: Buffer.from('fake content') } as never);
});

it('uses alt=media download URL for binary files (PDF)', async () => {
const req = makeReq([basePdfFile]);
const res = makeRes();
const handleUpload = jest.fn();
await handleGoogleDrive(req, res as unknown as express.Response, handleUpload);
expect(mockedAxios.get).toHaveBeenCalledWith(
'google_drive',
expect.stringContaining('?alt=media'),
expect.anything()
);
expect(handleUpload).toHaveBeenCalled();
const reqFiles = (req as unknown as { files: { originalname: string }[] }).files;
expect(reqFiles[0].originalname).toBe('lecture.pdf');
});

it('uses export URL for Google Docs and sets .html extension', async () => {
const req = makeReq([baseDocFile]);
const res = makeRes();
const handleUpload = jest.fn();
await handleGoogleDrive(req, res as unknown as express.Response, handleUpload);
expect(mockedAxios.get).toHaveBeenCalledWith(
'google_drive',
expect.stringContaining('/export?mimeType=text%2Fhtml'),
expect.anything()
);
const reqFiles = (req as unknown as { files: { originalname: string }[] }).files;
expect(reqFiles[0].originalname).toMatch(/\.html$/);
});

it('uses export URL for Google Sheets and sets .csv extension', async () => {
const req = makeReq([baseSheetFile]);
const res = makeRes();
const handleUpload = jest.fn();
await handleGoogleDrive(req, res as unknown as express.Response, handleUpload);
expect(mockedAxios.get).toHaveBeenCalledWith(
'google_drive',
expect.stringContaining('/export?mimeType=text%2Fcsv'),
expect.anything()
);
const reqFiles = (req as unknown as { files: { originalname: string }[] }).files;
expect(reqFiles[0].originalname).toMatch(/\.csv$/);
});

it('uses export URL for Google Slides and sets .pdf extension', async () => {
const req = makeReq([baseSlidesFile]);
const res = makeRes();
const handleUpload = jest.fn();
await handleGoogleDrive(req, res as unknown as express.Response, handleUpload);
expect(mockedAxios.get).toHaveBeenCalledWith(
'google_drive',
expect.stringContaining('/export?mimeType=application%2Fpdf'),
expect.anything()
);
const reqFiles = (req as unknown as { files: { originalname: string }[] }).files;
expect(reqFiles[0].originalname).toMatch(/\.pdf$/);
});

it('returns 400 and does not call handleUpload when googleDriveAuth is missing', async () => {
const req = makeReq([basePdfFile], undefined);
(req.body as Record<string, unknown>).googleDriveAuth = undefined;
const res = makeRes();
const handleUpload = jest.fn();
await handleGoogleDrive(req, res as unknown as express.Response, handleUpload);
expect(res.statusCode).toBe(400);
expect(handleUpload).not.toHaveBeenCalled();
});
});
36 changes: 27 additions & 9 deletions src/controllers/Upload/helpers/handleGoogleDrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,38 @@ import {
} from '../../../data_layer/GoogleDriveRepository';
import { isEmptyUpload } from './isEmptyUpload';
import { getFilesOrEmpty } from './getFilesOrEmpty';
import { createGoogleDriveDownloadLink } from './createGoogleDriveDownloadLink';
import {
createGoogleDriveDownloadLink,
createGoogleDriveExportLink,
NATIVE_GOOGLE_APPS_EXPORT_MIMES,
} from './createGoogleDriveDownloadLink';
import instrumentedAxios from '../../../services/observability/instrumentedAxios';

function resolveUrlAndName(
file: GoogleDriveFile
): { url: string; originalname: string } {
const exportSpec = NATIVE_GOOGLE_APPS_EXPORT_MIMES[file.mimeType];
if (exportSpec) {
const baseName = file.name.replace(/\.[^.]+$/, '');
return {
url: createGoogleDriveExportLink(file, exportSpec.exportMime),
originalname: baseName + exportSpec.extension,
};
}
return {
url: createGoogleDriveDownloadLink(file),
originalname: file.name,
};
}

export async function handleGoogleDrive(
req: express.Request,
res: express.Response,
handleUpload: (req: express.Request, res: express.Response) => void
) {
try {
console.log('handling Google Drive files', req.body);
const files = getFilesOrEmpty<GoogleDriveFile>(req.body);
if (isEmptyUpload(files)) {
console.debug('No Google Drive files selected.');
res.status(400).send('No Google Drive files selected, one is required.');
return;
}
Expand All @@ -50,16 +69,15 @@ export async function handleGoogleDrive(
const owner = getOwner(res);
if (owner) {
await repo.saveFiles(files, owner);
} else {
console.log('Not storing anon users Google Drive files');
}

// @ts-ignore
// @ts-ignore — Express request does not declare files in its type
req.files = await Promise.all(
files.map(async (file) => {
const { url, originalname } = resolveUrlAndName(file);
const contents = await instrumentedAxios.get(
'google_drive',
createGoogleDriveDownloadLink(file),
url,
{
headers: {
Authorization: `Bearer ${googleDriveAuth}`,
Expand All @@ -68,15 +86,15 @@ export async function handleGoogleDrive(
}
);
return {
originalname: file.name,
originalname,
size: file.sizeBytes,
buffer: contents.data,
};
})
);
handleUpload(req, res);
} catch (error) {
console.debug('Error handling Google files', error);
console.error('[handleGoogleDrive] failed:', error);
res.status(400).send('Error handling Google Drive files');
}
}
Loading