105 lines
3.2 KiB
JavaScript
105 lines
3.2 KiB
JavaScript
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
|
|
||
|
|
vi.mock('crypto', () => ({
|
||
|
|
randomUUID: vi.fn(() => 'test-uuid'),
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock('@vercel/blob', () => ({
|
||
|
|
put: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock('../../../lib/permission-middleware.js', () => ({
|
||
|
|
getUserFromRequest: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock('../../../lib/rate-limit.js', () => ({
|
||
|
|
checkUploadRateLimit: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { put } from '@vercel/blob';
|
||
|
|
import { getUserFromRequest } from '../../../lib/permission-middleware.js';
|
||
|
|
import { checkUploadRateLimit } from '../../../lib/rate-limit.js';
|
||
|
|
import handler from '../../../pages/api/scan/upload-image.js';
|
||
|
|
|
||
|
|
const TINY_JPEG =
|
||
|
|
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AL+AAf/Z';
|
||
|
|
|
||
|
|
function createRes() {
|
||
|
|
const res = {
|
||
|
|
statusCode: 200,
|
||
|
|
headers: {},
|
||
|
|
body: null,
|
||
|
|
status(code) {
|
||
|
|
res.statusCode = code;
|
||
|
|
return res;
|
||
|
|
},
|
||
|
|
json(data) {
|
||
|
|
res.body = data;
|
||
|
|
return res;
|
||
|
|
},
|
||
|
|
setHeader(name, value) {
|
||
|
|
res.headers[name] = value;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
return res;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('POST /api/scan/upload-image', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
getUserFromRequest.mockResolvedValue({ userId: 7, email: 'a@b.c', role: 'user' });
|
||
|
|
checkUploadRateLimit.mockResolvedValue({ allowed: true, remaining: 9, reset: Date.now() + 60_000 });
|
||
|
|
put.mockResolvedValue({ url: 'https://blob.example/scans/7/test-uuid.jpg' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 405 for non-POST methods', async () => {
|
||
|
|
const res = createRes();
|
||
|
|
await handler({ method: 'GET' }, res);
|
||
|
|
expect(res.statusCode).toBe(405);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 401 when unauthenticated', async () => {
|
||
|
|
getUserFromRequest.mockResolvedValue(null);
|
||
|
|
const res = createRes();
|
||
|
|
await handler({ method: 'POST', body: { imageData: TINY_JPEG } }, res);
|
||
|
|
expect(res.statusCode).toBe(401);
|
||
|
|
expect(put).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 429 when upload rate limit is exceeded', async () => {
|
||
|
|
checkUploadRateLimit.mockResolvedValue({
|
||
|
|
allowed: false,
|
||
|
|
remaining: 0,
|
||
|
|
reset: Date.now() + 30_000,
|
||
|
|
});
|
||
|
|
const res = createRes();
|
||
|
|
await handler({ method: 'POST', body: { imageData: TINY_JPEG } }, res);
|
||
|
|
expect(res.statusCode).toBe(429);
|
||
|
|
expect(res.headers['Retry-After']).toBeDefined();
|
||
|
|
expect(put).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns 400 for unsupported image MIME in data URL', async () => {
|
||
|
|
const res = createRes();
|
||
|
|
await handler(
|
||
|
|
{ method: 'POST', body: { imageData: 'data:image/gif;base64,AAAA' } },
|
||
|
|
res
|
||
|
|
);
|
||
|
|
expect(res.statusCode).toBe(400);
|
||
|
|
expect(res.body.error).toMatch(/JPEG, PNG, or WebP/);
|
||
|
|
expect(put).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uploads a valid JPEG data URL and returns the blob URL', async () => {
|
||
|
|
const res = createRes();
|
||
|
|
await handler({ method: 'POST', body: { imageData: TINY_JPEG } }, res);
|
||
|
|
expect(res.statusCode).toBe(200);
|
||
|
|
expect(res.body.url).toBe('https://blob.example/scans/7/test-uuid.jpg');
|
||
|
|
expect(put).toHaveBeenCalledWith(
|
||
|
|
'scans/7/test-uuid.jpg',
|
||
|
|
expect.any(Buffer),
|
||
|
|
expect.objectContaining({ access: 'public', contentType: 'image/jpeg' })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|