Skip to content

Commit f7666b3

Browse files
committed
new changes accept
2 parents 7f3814f + 491f995 commit f7666b3

7 files changed

Lines changed: 222 additions & 47 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/express": "^5.0.3",
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import path from 'path'
1212

1313
// Initialize Supabase client for storage operations
1414
export const supabase = createClient(
15-
config.DIRECT_URL,
16-
config.SUPABASE_ANON_KEY
15+
config.SUPABASE_URL,
16+
config.SUPABASE_SERVICE_ROLE_KEY
1717
)
1818

1919
const app = express()

src/config/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ 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_URL: process.env.SUPABASE_URL!,
6+
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY!,
67
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS || '*'
78
}

src/utils/imageUtils.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
function extractFilePathAndNameFromUrl(fileUrl: string): { filePath: string, fileName: string } {
8+
try {
9+
const url = new URL(fileUrl)
10+
const pathParts = url.pathname.split('/') // ['', 'storage', 'v1', 'object', 'public', 'images', ...filePathParts]
11+
const filePath = pathParts.slice(6).join('/') // 'images/...'
12+
if (!filePath) throw new Error('Invalid file URL')
13+
const fileName = filePath.split('/').pop() || ''
14+
return { filePath, fileName }
15+
} catch {
16+
throw new ApiError('Invalid file URL', 400)
17+
}
18+
}
19+
20+
21+
export async function uploadImage(
22+
supabase: SupabaseClient,
23+
file: MulterFile,
24+
folder: string,
25+
fileUrl?: string
26+
): Promise<string> {
27+
const mime = file.mimetype
28+
if (!mime) throw new ApiError('File type is missing', 400)
29+
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
30+
if (!allowedTypes.includes(mime)) {
31+
throw new ApiError('Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.', 400)
32+
}
33+
const ext = mime.split('/')[1]
34+
35+
let filename: string
36+
if (fileUrl) {
37+
const { fileName } = extractFilePathAndNameFromUrl(fileUrl)
38+
filename = fileName
39+
} else {
40+
filename = `${uuidv4()}.${ext}`
41+
}
42+
43+
const filePath = `${folder}/${filename}`
44+
45+
const { error: uploadError } = await supabase
46+
.storage
47+
.from('images')
48+
.upload(filePath, file.buffer, {
49+
contentType: file.mimetype,
50+
upsert: true,
51+
})
52+
53+
if (uploadError) {
54+
throw new ApiError(`Image upload failed: ${uploadError.message}`, 500)
55+
}
56+
57+
const { data: urlData } = supabase
58+
.storage
59+
.from('images')
60+
.getPublicUrl(filePath)
61+
62+
if (!urlData?.publicUrl) {
63+
throw new ApiError('Failed to get public URL', 500)
64+
}
65+
66+
return urlData.publicUrl
67+
}
68+
69+
export async function deleteImage(
70+
supabase: SupabaseClient,
71+
fileUrl: string
72+
): Promise<void> {
73+
const { filePath } = extractFilePathAndNameFromUrl(fileUrl)
74+
75+
const { error: deleteError } = await supabase
76+
.storage
77+
.from('images')
78+
.remove([filePath])
79+
80+
if (deleteError) {
81+
throw new ApiError(`Image deletion failed: ${deleteError.message}`, 500)
82+
}
83+
}

tests/Sample.test.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

tests/imageUtils.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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('uses existing filename from fileUrl if provided', async () => {
47+
// Simulate providing an existing URL so the helper extracts the filename
48+
const existingUrl = 'https://xyz.supabase.co/storage/v1/object/public/images/folder/existing.png';
49+
storageMock.upload.mockResolvedValue({ error: null });
50+
storageMock.getPublicUrl.mockReturnValue({ data: { publicUrl: 'https://public.url/existing.png' } });
51+
52+
const url = await uploadImage(
53+
mockSupabase as SupabaseClient,
54+
dummyFile,
55+
'folder',
56+
existingUrl
57+
);
58+
// Should use filename 'existing.png' instead of generating with uuid
59+
expect(storageMock.upload).toHaveBeenCalledWith(
60+
'folder/existing.png',
61+
dummyFile.buffer,
62+
{ contentType: dummyFile.mimetype, upsert: true }
63+
);
64+
expect(url).toBe('https://public.url/existing.png');
65+
});
66+
67+
it('throws ApiError for missing mimetype', async () => {
68+
const badFile = { ...dummyFile, mimetype: '' };
69+
await expect(
70+
uploadImage(mockSupabase as SupabaseClient, badFile as any, 'folder')
71+
).rejects.toThrow(ApiError);
72+
});
73+
74+
it('throws ApiError for invalid mimetype', async () => {
75+
const badFile = { ...dummyFile, mimetype: 'text/plain' };
76+
await expect(
77+
uploadImage(mockSupabase as SupabaseClient, badFile as any, 'folder')
78+
).rejects.toThrow(ApiError);
79+
});
80+
81+
it('throws ApiError when upload fails', async () => {
82+
storageMock.upload.mockResolvedValue({ error: { message: 'fail' } as PostgrestError });
83+
await expect(
84+
uploadImage(mockSupabase as SupabaseClient, dummyFile, 'folder')
85+
).rejects.toThrow(/Image upload failed/);
86+
});
87+
88+
it('throws ApiError when publicUrl missing', async () => {
89+
storageMock.upload.mockResolvedValue({ error: null });
90+
storageMock.getPublicUrl.mockReturnValue({ data: { publicUrl: '' } });
91+
await expect(
92+
uploadImage(mockSupabase as SupabaseClient, dummyFile, 'folder')
93+
).rejects.toThrow(/Failed to get public URL/);
94+
});
95+
});
96+
97+
describe('deleteImage', () => {
98+
it('deletes an image successfully', async () => {
99+
const publicUrl = 'https://xyz.supabase.co/storage/v1/object/public/images/folder/file.png';
100+
storageMock.remove.mockResolvedValue({ error: null });
101+
102+
await expect(
103+
deleteImage(mockSupabase as SupabaseClient, publicUrl)
104+
).resolves.toBeUndefined();
105+
expect(storageMock.from).toHaveBeenCalledWith('images');
106+
expect(storageMock.remove).toHaveBeenCalledWith(['folder/file.png']);
107+
});
108+
109+
it('throws ApiError for invalid URL', async () => {
110+
await expect(
111+
deleteImage(mockSupabase as SupabaseClient, 'https://invalid.url')
112+
).rejects.toThrow(ApiError);
113+
});
114+
115+
it('throws ApiError when delete fails', async () => {
116+
const publicUrl = 'https://xyz.supabase.co/storage/v1/object/public/images/folder/file.png';
117+
storageMock.remove.mockResolvedValue({ error: { message: 'delete fail' } as PostgrestError });
118+
119+
await expect(
120+
deleteImage(mockSupabase as SupabaseClient, publicUrl)
121+
).rejects.toThrow(/Image deletion failed/);
122+
});
123+
});
124+
});

0 commit comments

Comments
 (0)