Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
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
23 changes: 21 additions & 2 deletions backend/__tests__/board.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const app = express();
const Board = require('../models/Board.model');

let mongoServer;
let boardId;

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Expand All @@ -24,8 +26,6 @@ afterAll(async () => {
});

describe('Board Controller Tests', () => {
let boardId;

it('should create a new board', async () => {
const newBoard = {
title: 'Test Board',
Expand Down Expand Up @@ -147,4 +147,23 @@ describe('Board Controller Tests', () => {

expect(response.body.error).toBe('Database error');
});

it('should get boards by orgId', async () => {
const response = await request(app)
.get(`/boards/org/org123`)
.expect(200);

expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(1);
expect(response.body[0].orgId).toBe('org123');
});

it('should return an empty array if no boards are found for a given orgId', async () => {
const response = await request(app)
.get(`/boards/org/unknownOrg`)
.expect(200);

expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
});
106 changes: 106 additions & 0 deletions backend/__tests__/image.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,28 @@ const { MongoMemoryServer } = require('mongodb-memory-server');
const imageUploadRoutes = require('../views/ImageUpload.view');
const app = express();
const path = require('path');
const {
uploadFileByUrl,
mimeToExtension,
} = require('../controllers/ImageUpload');

let mongoServer;
global.fetch = jest.fn();

function createMockReqRes(overrides = {}) {
const req = {
protocol: 'http',
get: jest.fn().mockReturnValue('localhost:3000'),
body: {},
file: null,
...overrides,
};
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
return { req, res };
}

describe('Image Upload Tests', () => {
beforeAll(async () => {
Expand Down Expand Up @@ -72,3 +92,89 @@ describe('Image Upload Tests', () => {
fs.unlinkSync(tmpFilePath);
});
});

describe('uploadFileByUrl', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should respond with 400 if url is not provided', async () => {
const { req, res } = createMockReqRes();
await uploadFileByUrl(req, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'URL not provided' });
});

it('should handle fetch failures (non-OK response)', async () => {
const { req, res } = createMockReqRes({
body: { url: 'http://example.com/test.png' },
});

// Mock fetch returning a 404 or similar
global.fetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
});

await uploadFileByUrl(req, res);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Failed to upload file from URL',
});
});

it('should reject unsupported MIME type', async () => {
const { req, res } = createMockReqRes({
body: { url: 'http://example.com/test.exe' },
});

global.fetch.mockResolvedValue({
ok: true,
headers: {
get: () => 'application/octet-stream', // unsupported
},
arrayBuffer: async () => new ArrayBuffer(8),
});

await uploadFileByUrl(req, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Unsupported image mime type',
});
});

it('should handle exceptions and return 500', async () => {
const { req, res } = createMockReqRes({
body: { url: 'http://example.com/test.png' },
});

global.fetch.mockImplementation(() => {
throw new Error('Network failure');
});

await uploadFileByUrl(req, res);

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Failed to upload file from URL',
});
});
});

describe('mimeToExtension', () => {
test('should return correct extension for known mime types', () => {
expect(mimeToExtension('image/jpeg')).toBe('.jpg');
expect(mimeToExtension('image/png')).toBe('.png');
expect(mimeToExtension('image/gif')).toBe('.gif');
expect(mimeToExtension('image/svg+xml')).toBe('.svg');
});

test('should return empty string for unknown mime types', () => {
expect(mimeToExtension('application/json')).toBe('');
expect(mimeToExtension('text/plain')).toBe('');
});
});
16 changes: 14 additions & 2 deletions backend/controllers/Board.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ exports.getById = async (req, res) => {

exports.getByOrgId = async (req, res) => {
try {
const boards = await Board.find({ orgId: req.params.orgId });
// Сортируем по `createdAt` в порядке убывания (-1)
const boards = await Board.find({ orgId: req.params.orgId }).sort({
createdAt: -1,
});
return res.status(200).json(boards);
} catch (error) {
return res.status(500).json({ error: error.message });
Expand All @@ -29,7 +32,16 @@ exports.getByOrgId = async (req, res) => {

exports.createBoard = async (req, res) => {
try {
const board = await Board.create(req.body);
const { title, orgId, authorId, imageUrl } = req.body;

const board = await Board.create({
title,
orgId,
authorId,
imageUrl,
createdAt: new Date(), // Сохраняем в UTC
});

return res.status(200).json(board);
} catch (error) {
return res.status(500).json({ error: error.message });
Expand Down
119 changes: 91 additions & 28 deletions backend/controllers/ImageUpload.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,39 @@ const upload = multer({

exports.uploadMiddleware = upload.single('file');

function getFileHash(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex');
}

async function storeImageIfNotExists(buffer, originalName, mimeType) {
const hash = getFileHash(buffer);

let imageDoc = await Image.findOne({ hash });
if (imageDoc) {
return { imageDoc, deduplicated: true };
}

const extension = path.extname(originalName) || mimeToExtension(mimeType);
const filename = `${uuidv4()}${extension}`;
const uploadDir = 'uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}

const fullPath = path.join(uploadDir, filename);
fs.writeFileSync(fullPath, buffer);

imageDoc = await Image.create({
filename,
path: path.join('uploads', filename),
originalName,
mimeType,
hash,
});

return { imageDoc, deduplicated: false };
}

exports.uploadFile = async (req, res) => {
try {
if (req.fileValidationError || !req.file) {
Expand All @@ -38,44 +71,74 @@ exports.uploadFile = async (req, res) => {
.json({ error: 'No file uploaded or invalid file type.' });
}

const fileBuffer = req.file.buffer;
const hash = crypto
.createHash('sha256')
.update(fileBuffer)
.digest('hex');
let imageDoc = await Image.findOne({ hash });

if (imageDoc) {
return res.status(200).json({
url: `${req.protocol}://${req.get('host')}/${imageDoc.path}`,
deduplicated: true,
});
const { file } = req;
const { imageDoc, deduplicated } = await storeImageIfNotExists(
file.buffer,
file.originalname,
file.mimetype,
);

return res.status(200).json({
url: `${req.protocol}://${req.get('host')}/${imageDoc.path}`,
deduplicated,
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Failed to upload file' });
}
};

exports.uploadFileByUrl = async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL not provided' });
}

const ext = path.extname(req.file.originalname);
const filename = `${uuidv4()}${ext}`;
const uploadDir = 'uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
const remoteRes = await fetch(url);
if (!remoteRes.ok) {
throw new Error(`Failed to fetch image: ${remoteRes.statusText}`);
}

const fullPath = path.join(uploadDir, filename);
fs.writeFileSync(fullPath, fileBuffer);
const contentType = remoteRes.headers.get('content-type') || '';
if (!ALLOWED_MIME_TYPES.includes(contentType)) {
return res
.status(400)
.json({ error: 'Unsupported image mime type' });
}

imageDoc = await Image.create({
filename,
path: `uploads/${filename}`,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
hash,
});
const fileBuffer = Buffer.from(await remoteRes.arrayBuffer());
const { imageDoc, deduplicated } = await storeImageIfNotExists(
fileBuffer,
'downloaded-from-url',
contentType,
);

return res.status(200).json({
url: `${req.protocol}://${req.get('host')}/${imageDoc.path}`,
deduplicated: false,
deduplicated,
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Failed to upload file' });
return res
.status(500)
.json({ error: 'Failed to upload file from URL' });
}
};

function mimeToExtension(mimeType) {
switch (mimeType) {
case 'image/jpeg':
case 'image/jpg':
return '.jpg';
case 'image/png':
return '.png';
case 'image/gif':
return '.gif';
case 'image/svg+xml':
return '.svg';
default:
return '';
}
}
exports.mimeToExtension = mimeToExtension;
2 changes: 1 addition & 1 deletion backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ app.use((req, res, next) => {
});

mongoose
.connect('mongodb://localhost:27017/mango')
.connect('mongodb://127.0.0.1:27017/mango')
.then(() => {
console.log('Database connected');
})
Expand Down
8 changes: 7 additions & 1 deletion backend/views/ImageUpload.view.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
const { uploadMiddleware, uploadFile } = require('../controllers/ImageUpload');
const {
uploadMiddleware,
uploadFile,
uploadFileByUrl,
} = require('../controllers/ImageUpload');

module.exports = function (app) {
app.post('/uploads', uploadMiddleware, uploadFile);

app.post('/uploads-by-url', uploadFileByUrl);
};
4 changes: 3 additions & 1 deletion frontend/actions/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ export async function getBoardById(
}

export async function createBoard(userId: string, orgId: string) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const boards = await getAllBoards(userId, orgId);
return await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/boards`, {
title: 'Untitle-' + boards.length,
orgId: orgId,
orgId,
authorId: userId,
imageUrl: `/placeholders/${Math.floor(Math.random() * 10) + 1}.svg`,
timezone,
});
}

Expand Down
Loading
Loading