Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
exports.up = async (knex) => {
await knex.schema.table('google_drive_uploads', (t) => {
t.timestamp('last_converted_at', { useTz: true })
.defaultTo(knex.fn.now())
.nullable();
t.index('owner');
});
};

exports.down = async (knex) => {
await knex.schema.table('google_drive_uploads', (t) => {
t.dropIndex('owner');
t.dropColumn('last_converted_at');
});
};
197 changes: 197 additions & 0 deletions src/controllers/Upload/GoogleDriveController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import express from 'express';

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().mockResolvedValue([]) },
}));

import { INotionRepository } from '../../data_layer/NotionRespository';
import { IUploadRepository } from '../../data_layer/UploadRespository';
import NotionTokens from '../../data_layer/public/NotionTokens';
import NotionService from '../../services/NotionService';
import UploadService from '../../services/UploadService';
import JobRepository from '../../data_layer/JobRepository';
import UploadController from './UploadController';
import { GetGoogleDriveUploadsUseCase } from '../../usecases/uploads/GetGoogleDriveUploadsUseCase';
import { DeleteGoogleDriveUploadUseCase } from '../../usecases/uploads/DeleteGoogleDriveUploadUseCase';

function makeController(
getUseCase: GetGoogleDriveUploadsUseCase,
deleteUseCase: DeleteGoogleDriveUploadUseCase
) {
const uploadRepository: IUploadRepository = {
deleteUpload: jest.fn().mockResolvedValue(1),
getUploadsByOwner: jest.fn().mockResolvedValue([]),
findByIdAndOwner: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue([]),
};
const notionRepository: INotionRepository = {
getNotionData: jest.fn().mockResolvedValue({ owner: 1, token: '...' } as NotionTokens),
saveNotionToken: jest.fn().mockResolvedValue(true),
getNotionToken: jest.fn().mockResolvedValue('...'),
deleteBlocksByOwner: jest.fn().mockResolvedValue(1),
deleteNotionData: jest.fn().mockResolvedValue(true),
};
const uploadService = new UploadService(
uploadRepository,
{} as JobRepository
);
const notionService = new NotionService(notionRepository);
return new UploadController(
uploadService,
notionService,
undefined,
undefined,
getUseCase,
deleteUseCase
);
}

function makeRes(owner: number | null = 42) {
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
return {
res: { locals: { owner }, status, json } as unknown as express.Response,
json,
status,
};
}

describe('UploadController.getGoogleDriveUploads', () => {
it('returns 401 when owner is missing', async () => {
const getUseCase = { execute: jest.fn().mockResolvedValue([]) } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res, status, json } = makeRes(null);

await controller.getGoogleDriveUploads({ query: {} } as express.Request, res);

expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) }));
expect(getUseCase.execute).not.toHaveBeenCalled();
});

it('returns uploads when owner is present', async () => {
const rows = [
{
id: 'abc',
iconUrl: 'https://drive-thirdparty.googleusercontent.com/16/type/pdf',
mimeType: 'application/pdf',
name: 'file.pdf',
sizeBytes: '1024',
url: 'https://drive.google.com/file/d/abc/view',
last_converted_at: null,
},
];
const getUseCase = { execute: jest.fn().mockResolvedValue(rows) } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res, json } = makeRes(42);

await controller.getGoogleDriveUploads({ query: {} } as express.Request, res);

expect(json).toHaveBeenCalledWith(rows);
});

it('passes parsed offset to use case', async () => {
const getUseCase = { execute: jest.fn().mockResolvedValue([]) } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res } = makeRes(42);

await controller.getGoogleDriveUploads(
{ query: { offset: '20' } } as unknown as express.Request,
res
);

expect(getUseCase.execute).toHaveBeenCalledWith(42, 10, 20);
});
});

describe('UploadController.deleteGoogleDriveUpload', () => {
it('returns 401 when owner is missing', async () => {
const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res, status, json } = makeRes(null);

await controller.deleteGoogleDriveUpload(
{ params: { id: 'abc' } } as unknown as express.Request,
res
);

expect(status).toHaveBeenCalledWith(401);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) }));
expect(deleteUseCase.execute).not.toHaveBeenCalled();
});

it('returns 400 when id param is missing', async () => {
const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res, status, json } = makeRes(42);

await controller.deleteGoogleDriveUpload(
{ params: {} } as unknown as express.Request,
res
);

expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) }));
});

it('returns 400 when id contains characters outside the allowed alphabet', async () => {
const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res, status, json } = makeRes(42);

await controller.deleteGoogleDriveUpload(
{ params: { id: "abc'; DROP TABLE x;--" } } as unknown as express.Request,
res
);

expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) }));
expect(deleteUseCase.execute).not.toHaveBeenCalled();
});

it('returns 404 when use case throws', async () => {
const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = {
execute: jest.fn().mockRejectedValue(new Error('Not found')),
} as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res, status, json } = makeRes(42);

await controller.deleteGoogleDriveUpload(
{ params: { id: 'xyz' } } as unknown as express.Request,
res
);

expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) }));
});

it('returns 200 on successful delete and passes the string id', async () => {
const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase;
const deleteUseCase = { execute: jest.fn().mockResolvedValue(undefined) } as unknown as DeleteGoogleDriveUploadUseCase;
const controller = makeController(getUseCase, deleteUseCase);
const { res, json } = makeRes(42);

await controller.deleteGoogleDriveUpload(
{ params: { id: 'abc-123_XYZ' } } as unknown as express.Request,
res
);

expect(json).toHaveBeenCalledWith({});
expect(deleteUseCase.execute).toHaveBeenCalledWith('abc-123_XYZ', 42);
});
});
52 changes: 51 additions & 1 deletion src/controllers/Upload/UploadController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,21 @@
import { handleGoogleDrive } from './helpers/handleGoogleDrive';
import { GetDropboxUploadsUseCase } from '../../usecases/uploads/GetDropboxUploadsUseCase';
import { DeleteDropboxUploadUseCase } from '../../usecases/uploads/DeleteDropboxUploadUseCase';
import { GetGoogleDriveUploadsUseCase } from '../../usecases/uploads/GetGoogleDriveUploadsUseCase';
import { DeleteGoogleDriveUploadUseCase } from '../../usecases/uploads/DeleteGoogleDriveUploadUseCase';

const DROPBOX_PAGE_SIZE = 10;
const GOOGLE_DRIVE_PAGE_SIZE = 10;
const GOOGLE_DRIVE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;

class UploadController {
constructor(
private readonly service: UploadService,
private readonly notionService: NotionService,
private readonly getDropboxUploadsUseCase?: GetDropboxUploadsUseCase,
private readonly deleteDropboxUploadUseCase?: DeleteDropboxUploadUseCase
private readonly deleteDropboxUploadUseCase?: DeleteDropboxUploadUseCase,
private readonly getGoogleDriveUploadsUseCase?: GetGoogleDriveUploadsUseCase,
private readonly deleteGoogleDriveUploadUseCase?: DeleteGoogleDriveUploadUseCase
) {}

async deleteUpload(req: express.Request, res: express.Response) {
Expand Down Expand Up @@ -149,6 +155,50 @@
return res.status(404).json({ message: 'Upload not found.' });
}
}

async getGoogleDriveUploads(req: express.Request, res: express.Response) {
const owner = getOwner(res);
if (owner == null) {
return res.status(401).json({ message: 'Authentication required.' });
}

const rawOffset = (req.query as Record<string, string>).offset;
const offset = rawOffset != null ? parseInt(rawOffset, 10) : 0;

Check warning on line 166 in src/controllers/Upload/UploadController.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=2anki_server&issues=AZ4tuaIVzgvZm-J9I4Nv&open=AZ4tuaIVzgvZm-J9I4Nv&pullRequest=2305

Check warning on line 166 in src/controllers/Upload/UploadController.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=2anki_server&issues=AZ4tuaIVzgvZm-J9I4Nw&open=AZ4tuaIVzgvZm-J9I4Nw&pullRequest=2305

try {
const uploads = await this.getGoogleDriveUploadsUseCase!.execute(
owner,
GOOGLE_DRIVE_PAGE_SIZE,
Number.isFinite(offset) ? offset : 0
);
return res.json(uploads);
} catch (error) {
console.error('getGoogleDriveUploads failed', error);
return res.status(500).json({
message:
"Couldn't load your Google Drive history right now. Refresh to try again.",
});
}
}

async deleteGoogleDriveUpload(req: express.Request, res: express.Response) {
const owner = getOwner(res);
if (owner == null) {
return res.status(401).json({ message: 'Authentication required.' });
}

const rawId = (req.params as Record<string, string>).id;
if (rawId == null || !GOOGLE_DRIVE_ID_PATTERN.test(rawId)) {
return res.status(400).json({ message: 'Invalid upload id.' });
}

try {
await this.deleteGoogleDriveUploadUseCase!.execute(rawId, owner);
return res.json({});
} catch (error) {
return res.status(404).json({ message: 'Upload not found.' });
}

Check warning on line 200 in src/controllers/Upload/UploadController.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=2anki_server&issues=AZ4tuaIVzgvZm-J9I4Nx&open=AZ4tuaIVzgvZm-J9I4Nx&pullRequest=2305
}
}

export default UploadController;
Loading