Skip to content

Commit a8a059c

Browse files
committed
Add image upload and deletion utilities with tests
1 parent eaa95db commit a8a059c

7 files changed

Lines changed: 192 additions & 5 deletions

File tree

bun.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@
99
"scripts": {
1010
"test": "jest",
1111
"apidoc": "apidoc -c apidoc.json",
12-
"start" : "bun run src/server.ts",
12+
"start": "bun src/server.ts",
1313
"lint": "eslint src/",
1414
"format": "prettier --write src/",
1515
"lint:fix": "eslint src/ --fix",
1616
"precommit": "lint-staged",
17-
"migrate:first":"bunx prisma migrate dev --name init",
17+
"migrate:first": "bunx prisma migrate dev --name init",
1818
"migrate": "bunx prisma migrate dev",
1919
"generate": "bunx prisma generate"
2020
},
@@ -54,6 +54,7 @@
5454
"@types/jest": "^30.0.0",
5555
"@types/multer": "^2.0.0",
5656
"@types/node": "^24.0.13",
57+
"@types/uuid": "^10.0.0",
5758
"apidoc": "^1.2.0",
5859
"body-parser": "^2.2.0",
5960
"cors": "^2.8.5",
@@ -63,7 +64,8 @@
6364
"jest": "^30.0.4",
6465
"multer": "^2.0.1",
6566
"ts-jest": "^29.4.0",
66-
"ts-node": "^10.9.2"
67+
"ts-node": "^10.9.2",
68+
"uuid": "^11.1.0"
6769
},
6870
"private": true
6971
}

src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import path from 'path'
1313
// Initialize Supabase client for storage operations
1414
export const supabase = createClient(
1515
config.DIRECT_URL,
16-
config.SUPABASE_ANON_KEY
16+
config.SUPABASE_SERVICE_ROLE_KEY
1717
)
1818

1919
const app = express()

src/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ export default {
22
PORT: process.env.PORT,
33
DATABASE_URL: process.env.DATABASE_URL!,
44
DIRECT_URL: process.env.DIRECT_URL!,
5-
SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY!,
5+
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY!,
66
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS || '*'
77
}

src/utils/imageUtils.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { SupabaseClient } from '@supabase/supabase-js'
2+
import { v4 as uuidv4 } from 'uuid'
3+
import { ApiError } from './apiError'
4+
5+
type MulterFile = Express.Multer.File
6+
7+
export async function uploadImage(
8+
supabase: SupabaseClient,
9+
file: MulterFile,
10+
folder: string,
11+
fileName?: string
12+
): Promise<string> {
13+
// 1) Determine file extension
14+
const mime = file.mimetype // e.g. 'image/jpeg'
15+
if (!mime) {
16+
throw new ApiError('File type is missing', 400)
17+
}
18+
// Validate MIME type against allowed image types
19+
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
20+
if (!allowedTypes.includes(mime)) {
21+
throw new ApiError('Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.', 400)
22+
}
23+
24+
const ext = mime.split('/')[1]
25+
26+
// 2) Create filename
27+
const filename = fileName ? fileName : `${uuidv4()}.${ext}`
28+
29+
// 3) Build full path inside the bucket
30+
const filePath = `${folder}/${filename}`
31+
32+
// 4) Perform the upload (upsert: true will overwrite same path if it exists)
33+
const { error: uploadError } = await supabase
34+
.storage
35+
.from('images')
36+
.upload(filePath, file.buffer, {
37+
contentType: file.mimetype,
38+
upsert: true,
39+
})
40+
41+
if (uploadError) {
42+
throw new ApiError(`Image upload failed: ${uploadError.message}`, 500)
43+
}
44+
45+
// 5) Generate a public URL
46+
const { data: urlData } = supabase
47+
.storage
48+
.from('images')
49+
.getPublicUrl(filePath)
50+
51+
if (!urlData?.publicUrl) {
52+
throw new ApiError('Failed to get public URL', 500)
53+
}
54+
55+
return urlData.publicUrl
56+
}
57+
58+
export async function deleteImage(
59+
supabase: SupabaseClient,
60+
fileUrl: string
61+
): Promise<void> {
62+
const url = new URL(fileUrl);
63+
const pathParts = url.pathname.split('/'); // ['', 'storage', 'v1', 'object', 'public', 'images', ...filePathParts]
64+
const filePath = pathParts.slice(6).join('/'); // 'images/...'
65+
if (!filePath) {
66+
throw new ApiError('Invalid file URL', 400)
67+
}
68+
const { error: deleteError } = await supabase
69+
.storage
70+
.from('images')
71+
.remove([filePath])
72+
73+
if (deleteError) {
74+
throw new ApiError(`Image deletion failed: ${deleteError.message}`, 500)
75+
}
76+
}

tests/imageUtils.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { uploadImage, deleteImage } from '../src/utils/imageUtils';
2+
import { SupabaseClient, PostgrestError } from '@supabase/supabase-js';
3+
import { ApiError } from '../src/utils/apiError';
4+
import { v4 as uuidv4 } from 'uuid';
5+
6+
jest.mock('uuid');
7+
const mockedUuid = uuidv4 as jest.Mock;
8+
mockedUuid.mockReturnValue('test-uuid');
9+
10+
describe('imageUtils', () => {
11+
let mockSupabase: Partial<SupabaseClient>;
12+
let storageMock: any;
13+
14+
const dummyFile = {
15+
buffer: Buffer.from('test'),
16+
mimetype: 'image/png',
17+
} as Express.Multer.File;
18+
19+
beforeEach(() => {
20+
storageMock = {
21+
from: jest.fn().mockReturnThis(),
22+
upload: jest.fn(),
23+
getPublicUrl: jest.fn(),
24+
remove: jest.fn(),
25+
};
26+
27+
mockSupabase = {
28+
storage: storageMock,
29+
};
30+
});
31+
32+
describe('uploadImage', () => {
33+
it('uploads an image successfully and returns public URL', async () => {
34+
storageMock.upload.mockResolvedValue({ error: null });
35+
storageMock.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://public.url/file.png' } });
36+
37+
const url = await uploadImage(mockSupabase as SupabaseClient, dummyFile, 'test-folder');
38+
expect(storageMock.from).toHaveBeenCalledWith('images');
39+
expect(storageMock.upload).toHaveBeenCalledWith('test-folder/test-uuid.png', dummyFile.buffer, {
40+
contentType: dummyFile.mimetype,
41+
upsert: true,
42+
});
43+
expect(url).toBe('https://public.url/file.png');
44+
});
45+
46+
it('throws ApiError for missing mimetype', async () => {
47+
const badFile = { ...dummyFile, mimetype: '' };
48+
await expect(
49+
uploadImage(mockSupabase as SupabaseClient, badFile as any, 'folder')
50+
).rejects.toThrow(ApiError);
51+
});
52+
53+
it('throws ApiError for invalid mimetype', async () => {
54+
const badFile = { ...dummyFile, mimetype: 'text/plain' };
55+
await expect(
56+
uploadImage(mockSupabase as SupabaseClient, badFile as any, 'folder')
57+
).rejects.toThrow(ApiError);
58+
});
59+
60+
it('throws ApiError when upload fails', async () => {
61+
storageMock.upload.mockResolvedValue({ error: { message: 'fail' } as PostgrestError });
62+
await expect(
63+
uploadImage(mockSupabase as SupabaseClient, dummyFile, 'folder')
64+
).rejects.toThrow(/Image upload failed/);
65+
});
66+
67+
it('throws ApiError when publicUrl missing', async () => {
68+
storageMock.upload.mockResolvedValue({ error: null });
69+
storageMock.getPublicUrl.mockReturnValue({ data: { publicUrl: '' } });
70+
await expect(
71+
uploadImage(mockSupabase as SupabaseClient, dummyFile, 'folder')
72+
).rejects.toThrow(/Failed to get public URL/);
73+
});
74+
});
75+
76+
describe('deleteImage', () => {
77+
it('deletes an image successfully', async () => {
78+
const publicUrl = 'https://xyz.supabase.co/storage/v1/object/public/images/folder/file.png';
79+
storageMock.remove.mockResolvedValue({ error: null });
80+
81+
await expect(
82+
deleteImage(mockSupabase as SupabaseClient, publicUrl)
83+
).resolves.toBeUndefined();
84+
expect(storageMock.from).toHaveBeenCalledWith('images');
85+
expect(storageMock.remove).toHaveBeenCalledWith(['folder/file.png']);
86+
});
87+
88+
it('throws ApiError for invalid URL', async () => {
89+
await expect(
90+
deleteImage(mockSupabase as SupabaseClient, 'https://invalid.url')
91+
).rejects.toThrow(ApiError);
92+
});
93+
94+
it('throws ApiError when delete fails', async () => {
95+
const publicUrl = 'https://xyz.supabase.co/storage/v1/object/public/images/folder/file.png';
96+
storageMock.remove.mockResolvedValue({ error: { message: 'delete fail' } as PostgrestError });
97+
98+
await expect(
99+
deleteImage(mockSupabase as SupabaseClient, publicUrl)
100+
).rejects.toThrow(/Image deletion failed/);
101+
});
102+
});
103+
});

0 commit comments

Comments
 (0)