-
Notifications
You must be signed in to change notification settings - Fork 68
Add unit tests for storage.controller.js #72
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
yash-pouranik
merged 5 commits into
geturbackend:main
from
ayash911:feature/storage-tests
Apr 3, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
4933787
Add unit tests for storage.controller.js
ayash911 4dc7218
Address PR testing review: block path traversal, refine assertions, aβ¦
ayash911 19124a2
fix(public-api): resolve PR review comments on storage controller
ayash911 d73e111
chore: update package-lock.json after adding jest devDependency
ayash911 8b41a74
Merge branch 'main' into feature/storage-tests
yash-pouranik File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
350 changes: 350 additions & 0 deletions
350
apps/public-api/src/__tests__/storage.controller.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,350 @@ | ||
| 'use strict'; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Mock dependencies | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| jest.mock('crypto', () => ({ | ||
| randomUUID: jest.fn(() => 'mocked-uuid'), | ||
| })); | ||
|
|
||
| jest.mock('@urbackend/common', () => { | ||
| const mockStorageFrom = { | ||
| upload: jest.fn(), | ||
| getPublicUrl: jest.fn(), | ||
| remove: jest.fn(), | ||
| list: jest.fn(), | ||
| }; | ||
|
|
||
| const mockSupabaseStorage = { | ||
| from: jest.fn(() => mockStorageFrom), | ||
| }; | ||
|
|
||
| return { | ||
| getStorage: jest.fn(() => ({ | ||
| storage: mockSupabaseStorage, | ||
| })), | ||
| Project: { | ||
| updateOne: jest.fn(), | ||
| }, | ||
| isProjectStorageExternal: jest.fn(), | ||
| __mockStorageFrom: mockStorageFrom, // expose for assertions | ||
| }; | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Import module under test after mocks | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const { getStorage, Project, isProjectStorageExternal, __mockStorageFrom: mockStorageFrom } = require('@urbackend/common'); | ||
| const storageController = require('../controllers/storage.controller'); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const makeRes = () => { | ||
| const res = { status: jest.fn(), json: jest.fn() }; | ||
| res.status.mockReturnValue(res); | ||
| res.json.mockReturnValue(res); | ||
| return res; | ||
| }; | ||
|
|
||
| const makeProject = (overrides = {}) => ({ | ||
| _id: 'project_id_1', | ||
| name: 'TestProject', | ||
| storageLimit: 100000000, | ||
| storageUsed: 5000000, | ||
| ...overrides, | ||
| }); | ||
|
|
||
| const makeFile = (overrides = {}) => ({ | ||
| originalname: 'test file.txt', | ||
| mimetype: 'text/plain', | ||
| size: 1024, | ||
| buffer: Buffer.from('test data'), | ||
| ...overrides, | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Tests | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| describe('storage.controller', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| process.env.NODE_ENV = 'test'; | ||
| }); | ||
|
|
||
| describe('uploadFile', () => { | ||
| test('returns 400 when no file is uploaded', async () => { | ||
| const req = { project: makeProject(), file: null }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.uploadFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(400); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'No file uploaded.' }); | ||
| }); | ||
|
|
||
| test('returns 413 when file exceeds MAX_FILE_SIZE', async () => { | ||
| const req = { | ||
| project: makeProject(), | ||
| file: makeFile({ size: 15 * 1024 * 1024 }) // 15MB | ||
| }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.uploadFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(413); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'File size exceeds limit.' }); | ||
| }); | ||
|
|
||
| test('returns 403 when internal storage quota is exceeded', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| Project.updateOne.mockResolvedValue({ matchedCount: 0 }); // Simulates constraint failure | ||
|
|
||
| const req = { project: makeProject(), file: makeFile() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.uploadFile(req, res); | ||
|
|
||
| expect(Project.updateOne).toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'Internal storage limit exceeded.' }); | ||
| }); | ||
|
|
||
| test('returns 201 and public URL on successful internal upload', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| Project.updateOne.mockResolvedValue({ matchedCount: 1 }); | ||
| mockStorageFrom.upload.mockResolvedValue({ data: { path: 'mocked-path' }, error: null }); | ||
| mockStorageFrom.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://mock.supabase.co/mocked-path' } }); | ||
|
|
||
| const req = { project: makeProject(), file: makeFile() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.uploadFile(req, res); | ||
|
|
||
| expect(Project.updateOne).toHaveBeenCalledTimes(1); // Quota reservation | ||
| expect(mockStorageFrom.upload).toHaveBeenCalledWith( | ||
| 'project_id_1/mocked-uuid_test_file.txt', | ||
| req.file.buffer, | ||
| { contentType: 'text/plain', upsert: false } | ||
| ); | ||
| expect(res.status).toHaveBeenCalledWith(201); | ||
| expect(res.json).toHaveBeenCalledWith({ | ||
| message: 'File uploaded successfully', | ||
| url: 'https://mock.supabase.co/mocked-path', | ||
| path: 'project_id_1/mocked-uuid_test_file.txt', | ||
| provider: 'internal' | ||
| }); | ||
| }); | ||
|
|
||
| test('returns 201 and public URL on successful external upload (skips quota)', async () => { | ||
| isProjectStorageExternal.mockReturnValue(true); | ||
| mockStorageFrom.upload.mockResolvedValue({ data: { path: 'mocked-path' }, error: null }); | ||
| mockStorageFrom.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://mock.supabase.co/mocked-path' } }); | ||
|
|
||
| const req = { project: makeProject(), file: makeFile() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.uploadFile(req, res); | ||
|
|
||
| expect(Project.updateOne).not.toHaveBeenCalled(); // No quota reservation | ||
| expect(res.status).toHaveBeenCalledWith(201); | ||
| expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ provider: 'external' })); | ||
| }); | ||
|
|
||
| test('rolls back quota and returns 500 when upload fails', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| Project.updateOne.mockResolvedValue({ matchedCount: 1 }); | ||
| const error = new Error('Supabase Upload Failed'); | ||
| mockStorageFrom.upload.mockResolvedValue({ data: null, error }); | ||
|
|
||
| const req = { project: makeProject(), file: makeFile() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.uploadFile(req, res); | ||
|
|
||
| expect(Project.updateOne).toHaveBeenCalledTimes(2); // One for reservation, one for rollback | ||
| expect(res.status).toHaveBeenCalledWith(500); | ||
| expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'File upload failed' })); | ||
| }); | ||
| }); | ||
|
|
||
| describe('deleteFile', () => { | ||
| test('returns 400 when file path is missing', async () => { | ||
| const req = { project: makeProject(), body: {} }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(400); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'File path is required.' }); | ||
| }); | ||
|
|
||
| test('returns 403 when trying to delete file belonging to different project', async () => { | ||
| const req = { project: makeProject(), body: { path: 'wrong_project_id/file.txt' } }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'Access denied.' }); | ||
| }); | ||
|
ayash911 marked this conversation as resolved.
|
||
|
|
||
| test('returns 403 when trying to delete file using path traversal', async () => { | ||
| const req = { project: makeProject(), body: { path: 'project_id_1/../other_project/file.txt' } }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'Access denied.' }); | ||
| }); | ||
|
|
||
| test('returns 200 on successful internal deletion', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| mockStorageFrom.list.mockResolvedValue({ data: [{ metadata: { size: 1024 } }], error: null }); | ||
| mockStorageFrom.remove.mockResolvedValue({ data: [{ path: 'project_id_1/file.txt' }], error: null }); | ||
|
|
||
| const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteFile(req, res); | ||
|
|
||
| expect(mockStorageFrom.list).toHaveBeenCalledWith('project_id_1', { search: 'file.txt' }); | ||
| expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); | ||
| expect(Project.updateOne).toHaveBeenCalledWith( | ||
| { _id: 'project_id_1' }, | ||
| { $inc: { storageUsed: -1024 } } | ||
| ); | ||
| expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); | ||
| }); | ||
|
|
||
| test('returns 200 on successful external deletion (skips internal usage list)', async () => { | ||
| isProjectStorageExternal.mockReturnValue(true); | ||
| mockStorageFrom.remove.mockResolvedValue({ data: [{ path: 'project_id_1/file.txt' }], error: null }); | ||
|
|
||
| const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteFile(req, res); | ||
|
|
||
| expect(mockStorageFrom.list).not.toHaveBeenCalled(); | ||
| expect(Project.updateOne).not.toHaveBeenCalled(); | ||
| expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); | ||
| }); | ||
|
|
||
| test('returns 500 when Supabase list fails', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| mockStorageFrom.list.mockResolvedValue({ data: null, error: new Error('List failed') }); | ||
|
|
||
| const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteFile(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(500); | ||
| expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'File deletion failed' })); | ||
| }); | ||
|
|
||
| test('returns 500 when Supabase remove fails', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| mockStorageFrom.list.mockResolvedValue({ data: [{ metadata: { size: 1024 } }], error: null }); | ||
| mockStorageFrom.remove.mockResolvedValue({ data: null, error: new Error('Remove failed') }); | ||
|
|
||
| const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteFile(req, res); | ||
|
|
||
| expect(Project.updateOne).not.toHaveBeenCalled(); // Storage used shouldn't decrement | ||
| expect(res.status).toHaveBeenCalledWith(500); | ||
| }); | ||
| }); | ||
|
|
||
| describe('deleteAllFiles', () => { | ||
| test('returns 404 when project is not found', async () => { | ||
| const req = { project: null }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteAllFiles(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(404); | ||
| expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' }); | ||
| }); | ||
|
|
||
| test('deletes paginated files and resets internal storage to 0', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
|
|
||
| // first call returns 2 items, second call returns [] | ||
| mockStorageFrom.list | ||
| .mockResolvedValueOnce({ data: [{ name: 'file1.txt' }, { name: 'file2.txt' }], error: null }) | ||
| .mockResolvedValueOnce({ data: [], error: null }); | ||
|
|
||
| mockStorageFrom.remove.mockResolvedValue({ data: [{ path: 'project_id_1/file1.txt' }], error: null }); | ||
|
|
||
| const req = { project: makeProject() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteAllFiles(req, res); | ||
|
|
||
| expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file1.txt', 'project_id_1/file2.txt']); | ||
| expect(Project.updateOne).toHaveBeenCalledWith( | ||
| { _id: 'project_id_1' }, | ||
| { $set: { storageUsed: 0 } } | ||
| ); | ||
| expect(res.json).toHaveBeenCalledWith({ | ||
| success: true, | ||
| deleted: 2, | ||
| provider: 'internal' | ||
| }); | ||
| }); | ||
|
|
||
| test('skips storageUsed reset for external provider during deleteAllFiles', async () => { | ||
| isProjectStorageExternal.mockReturnValue(true); | ||
|
|
||
| mockStorageFrom.list | ||
| .mockResolvedValueOnce({ data: [{ name: 'file1.txt' }], error: null }) | ||
| .mockResolvedValueOnce({ data: [], error: null }); | ||
|
|
||
| mockStorageFrom.remove.mockResolvedValue({ data: [{}], error: null }); | ||
|
|
||
| const req = { project: makeProject() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteAllFiles(req, res); | ||
|
|
||
| expect(Project.updateOne).not.toHaveBeenCalled(); | ||
| expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ provider: 'external' })); | ||
| }); | ||
|
|
||
| test('returns 500 when Supabase remove fails during deleteAllFiles', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| mockStorageFrom.list.mockResolvedValue({ data: [{ name: 'file1.txt' }], error: null }); | ||
| mockStorageFrom.remove.mockResolvedValue({ data: null, error: new Error('Remove failed') }); | ||
|
|
||
| const req = { project: makeProject() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteAllFiles(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(500); | ||
| expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Failed to delete files' })); | ||
| }); | ||
|
|
||
| test('returns 500 if pagination fetch fails', async () => { | ||
| isProjectStorageExternal.mockReturnValue(false); | ||
| mockStorageFrom.list.mockResolvedValue({ data: null, error: new Error('Pagination error') }); | ||
|
|
||
| const req = { project: makeProject() }; | ||
| const res = makeRes(); | ||
|
|
||
| await storageController.deleteAllFiles(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(500); | ||
| expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Failed to delete files' })); | ||
| }); | ||
|
ayash911 marked this conversation as resolved.
|
||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π§© Analysis chain
π Script executed:
Repository: yash-pouranik/urBackend
Length of output: 647
Duplicate
devDependenciesblocks will use the wrong Jest version.The file contains two
devDependenciesblocks:"jest": "^29.7.0"(newly added)"jest": "^30.3.0"(pre-existing)JSON parsers use the last occurrence, so npm will resolve to
jest@30.3.0and ignore the intended^29.7.0. This contradicts the PR objective.Remove the first
devDependenciesblock (lines 14-16) to keep only the second one, then update it to use the intended version:"jest": { "testEnvironment": "node" }, - "devDependencies": { - "jest": "^29.7.0" - }, "dependencies": { "@kiroo/sdk": "^0.1.2" }, "devDependencies": { - "jest": "^30.3.0" + "jest": "^29.7.0" } }π§° Tools
πͺ Biome (2.4.9)
[error] 14-14: The key devDependencies was already declared.
(lint/suspicious/noDuplicateObjectKeys)
π€ Prompt for AI Agents