Skip to content

Commit 271adc5

Browse files
Merge pull request #367 from geturbackend/feature/clone-template
feat: Add clone project template functionality
2 parents 02b402a + dc96277 commit 271adc5

9 files changed

Lines changed: 637 additions & 56 deletions

File tree

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
'use strict';
2+
3+
const mongoose = require('mongoose');
4+
5+
/**
6+
* PROJECT_TEMPLATES — inline copy of just the templates createProject uses.
7+
* We keep this in the test so we never pull in the real @urbackend/common
8+
* (which initialises Redis, BullMQ, GC timers — all blow up in CI).
9+
*/
10+
const PROJECT_TEMPLATES = {
11+
'sdk-kanban': {
12+
name: 'Kanban Board',
13+
description: 'Full-featured Kanban board with drag-and-drop tasks.',
14+
isAuthEnabled: true,
15+
collections: [
16+
{
17+
name: 'boards',
18+
rls: 'private',
19+
model: [{ key: 'title', type: 'String', required: true }],
20+
},
21+
{
22+
name: 'tasks',
23+
rls: 'private',
24+
model: [
25+
{ key: 'boardId', type: 'Ref', required: true },
26+
{ key: 'title', type: 'String', required: true },
27+
{ key: 'status', type: 'String', required: true, default: 'todo' },
28+
],
29+
},
30+
],
31+
},
32+
};
33+
34+
/* ------------------------------------------------------------------ */
35+
/* Real Zod — needed for schema validation inside createProject */
36+
/* ------------------------------------------------------------------ */
37+
const { z } = require('zod');
38+
39+
/* Reconstruct the real createProjectSchema so parse() actually works */
40+
const createProjectSchema = z.object({
41+
name: z.string().min(1, 'Project name is required'),
42+
description: z.string().optional(),
43+
templateId: z.string().optional(),
44+
siteUrl: z.preprocess(
45+
(val) => (val === '' || val === null ? undefined : val),
46+
z
47+
.string()
48+
.url('Invalid Site URL format')
49+
.refine((url) => {
50+
try {
51+
const parsed = new URL(url);
52+
return (
53+
parsed.protocol === 'https:' ||
54+
(parsed.protocol === 'http:' &&
55+
['localhost', '127.0.0.1', '::1'].includes(parsed.hostname))
56+
);
57+
} catch {
58+
return false;
59+
}
60+
}, 'Site URL must use HTTPS (or http://localhost for local development)')
61+
.optional(),
62+
),
63+
});
64+
65+
/* ------------------------------------------------------------------ */
66+
/* Mock @urbackend/common — NO jest.requireActual to avoid side- */
67+
/* effects (Redis, BullMQ queues, GC timers, email service, etc.) */
68+
/* ------------------------------------------------------------------ */
69+
const mockSave = jest.fn();
70+
const mockCountDocuments = jest.fn();
71+
72+
// Minimal Project constructor mock that behaves like a Mongoose model
73+
function MockProject(data) {
74+
Object.assign(this, data);
75+
this._id = data._id || new mongoose.Types.ObjectId();
76+
this.collections = this.collections || [];
77+
this.isAuthEnabled = this.isAuthEnabled || false;
78+
}
79+
MockProject.prototype.save = mockSave;
80+
MockProject.prototype.toObject = function () {
81+
const obj = { ...this };
82+
// Simulate toObject stripping prototype methods
83+
delete obj.save;
84+
delete obj.toObject;
85+
return obj;
86+
};
87+
MockProject.countDocuments = mockCountDocuments;
88+
// For spying / instanceof checks
89+
MockProject.schema = { obj: {} };
90+
91+
jest.mock('@urbackend/common', () => ({
92+
Project: MockProject,
93+
PROJECT_TEMPLATES,
94+
createProjectSchema,
95+
generateApiKey: jest.fn(() => 'test_api_key'),
96+
hashApiKey: jest.fn(() => 'hashed_key'),
97+
encrypt: jest.fn(() => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })),
98+
markDeveloperOnboardingStep: jest.fn().mockResolvedValue(),
99+
AppError: class AppError extends Error {
100+
constructor(statusCode, message, error = null) {
101+
super(message);
102+
this.name = 'AppError';
103+
this.statusCode = statusCode;
104+
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
105+
this.error = error || (statusCode >= 500 ? 'Internal Server Error' : 'Error');
106+
this.isOperational = true;
107+
}
108+
},
109+
// Stubs for other imports used at module-level by project.controller.js
110+
Developer: { findById: jest.fn() },
111+
Log: { aggregate: jest.fn() },
112+
getStorage: jest.fn(),
113+
getConnection: jest.fn(),
114+
getCompiledModel: jest.fn(),
115+
QueryEngine: jest.fn(),
116+
storageRegistry: {},
117+
webhookQueue: { add: jest.fn() },
118+
enqueueCollectionCleanup: jest.fn(),
119+
syncCollectionCleanup: jest.fn(),
120+
resolveEffectivePlan: jest.fn(() => ({ name: 'free' })),
121+
deleteProjectByApiKeyCache: jest.fn(),
122+
setProjectById: jest.fn(),
123+
getProjectById: jest.fn(),
124+
deleteProjectById: jest.fn(),
125+
isProjectStorageExternal: jest.fn(() => false),
126+
getBucket: jest.fn(() => 'default'),
127+
getPresignedUploadUrl: jest.fn(),
128+
verifyUploadedFile: jest.fn(),
129+
getPublicIp: jest.fn(),
130+
clearCompiledModel: jest.fn(),
131+
createUniqueIndexes: jest.fn(),
132+
ApiAnalytics: { aggregate: jest.fn() },
133+
MailLog: { aggregate: jest.fn() },
134+
getProjectAccessQuery: jest.fn((userId) => ({
135+
$or: [{ owner: userId }, { 'members.user': userId }],
136+
})),
137+
getProjectRole: jest.fn(),
138+
Invitation: { find: jest.fn() },
139+
sanitizeObjectId: jest.fn((v) => v),
140+
sanitizeNonEmptyString: jest.fn((v) => v),
141+
createCollectionSchema: { parse: jest.fn((v) => v) },
142+
editCollectionSchema: { parse: jest.fn((v) => v) },
143+
updateExternalConfigSchema: { parse: jest.fn((v) => v) },
144+
updateAuthProvidersSchema: { parse: jest.fn((v) => v) },
145+
decrypt: jest.fn(() => 'decrypted'),
146+
getS3CompatibleStorage: jest.fn(),
147+
}));
148+
149+
jest.mock('../utils/emitEvent', () => ({
150+
emitEvent: jest.fn(),
151+
}));
152+
153+
jest.setTimeout(15000);
154+
155+
/* ------------------------------------------------------------------ */
156+
/* Tests */
157+
/* ------------------------------------------------------------------ */
158+
describe('Project Controller - Clone Template', () => {
159+
let req, res;
160+
161+
// Import AFTER mocks are set up
162+
const { createProject } = require('../controllers/project.controller');
163+
164+
beforeEach(() => {
165+
req = {
166+
body: {},
167+
user: { _id: new mongoose.Types.ObjectId(), isVerified: true },
168+
};
169+
res = {
170+
status: jest.fn().mockReturnThis(),
171+
json: jest.fn(),
172+
};
173+
jest.clearAllMocks();
174+
175+
// Simulate standalone MongoDB (no replica set) — triggers fallback path
176+
jest.spyOn(mongoose, 'startSession').mockRejectedValue(
177+
Object.assign(new Error('Transaction numbers are only allowed on a replica set member or mongos'), { code: 20 })
178+
);
179+
180+
// Mock save to behave like Mongoose save
181+
mockSave.mockImplementation(function () {
182+
this._id = this._id || new mongoose.Types.ObjectId();
183+
return Promise.resolve(this);
184+
});
185+
186+
mockCountDocuments.mockResolvedValue(0);
187+
});
188+
189+
afterEach(() => {
190+
jest.restoreAllMocks();
191+
});
192+
193+
it('should create a project with template configuration', async () => {
194+
req.body = {
195+
name: 'Test Clone Project',
196+
templateId: 'sdk-kanban',
197+
};
198+
199+
await createProject(req, res);
200+
201+
expect(res.status).toHaveBeenCalledWith(201);
202+
expect(res.json).toHaveBeenCalled();
203+
204+
const responseObj = res.json.mock.calls[0][0];
205+
expect(responseObj.success).toBe(true);
206+
expect(responseObj.data.name).toBe('Test Clone Project');
207+
expect(responseObj.data.isAuthEnabled).toBe(true);
208+
209+
const collectionNames = responseObj.data.collections.map((c) => c.name);
210+
expect(collectionNames).toContain('boards');
211+
expect(collectionNames).toContain('tasks');
212+
expect(collectionNames).toContain('users');
213+
214+
const boardsCol = responseObj.data.collections.find((c) => c.name === 'boards');
215+
const mode = typeof boardsCol.rls === 'string' ? boardsCol.rls : boardsCol.rls?.mode;
216+
expect(mode).toBe('private');
217+
});
218+
219+
it('should ignore invalid templateId and create a normal project', async () => {
220+
req.body = {
221+
name: 'Normal Project',
222+
templateId: 'invalid-template',
223+
};
224+
225+
await createProject(req, res);
226+
227+
expect(res.status).toHaveBeenCalledWith(201);
228+
const responseObj = res.json.mock.calls[0][0];
229+
expect(responseObj.success).toBe(true);
230+
expect(responseObj.data.name).toBe('Normal Project');
231+
expect(responseObj.data.collections).toHaveLength(0);
232+
expect(responseObj.data.isAuthEnabled).toBeFalsy();
233+
});
234+
235+
it('should create a project without templateId', async () => {
236+
req.body = {
237+
name: 'Blank Project',
238+
};
239+
240+
await createProject(req, res);
241+
242+
expect(res.status).toHaveBeenCalledWith(201);
243+
const responseObj = res.json.mock.calls[0][0];
244+
expect(responseObj.success).toBe(true);
245+
expect(responseObj.data.name).toBe('Blank Project');
246+
expect(responseObj.data.collections).toHaveLength(0);
247+
});
248+
249+
it('should return 400 for missing project name', async () => {
250+
req.body = {};
251+
252+
await createProject(req, res);
253+
254+
expect(res.status).toHaveBeenCalledWith(400);
255+
});
256+
257+
it('should enforce project limit when set', async () => {
258+
req.body = { name: 'Over Limit' };
259+
req.projectLimit = 2;
260+
mockCountDocuments.mockResolvedValue(2);
261+
262+
await createProject(req, res);
263+
264+
expect(res.status).toHaveBeenCalledWith(403);
265+
});
266+
267+
it('should add users collection with auth-enabled template', async () => {
268+
req.body = {
269+
name: 'Auth Template Project',
270+
templateId: 'sdk-kanban',
271+
};
272+
273+
await createProject(req, res);
274+
275+
expect(res.status).toHaveBeenCalledWith(201);
276+
const responseObj = res.json.mock.calls[0][0];
277+
const usersCol = responseObj.data.collections.find((c) => c.name === 'users');
278+
expect(usersCol).toBeDefined();
279+
280+
const usersMode = typeof usersCol.rls === 'string' ? usersCol.rls : usersCol.rls?.mode;
281+
expect(usersMode).toBe('private');
282+
283+
const emailField = usersCol.model.find((f) => f.key === 'email');
284+
const passwordField = usersCol.model.find((f) => f.key === 'password');
285+
expect(emailField).toBeDefined();
286+
expect(passwordField).toBeDefined();
287+
});
288+
289+
it('should create project successfully using transactional path', async () => {
290+
const mockSession = {
291+
startTransaction: jest.fn(),
292+
commitTransaction: jest.fn().mockResolvedValue(),
293+
abortTransaction: jest.fn().mockResolvedValue(),
294+
endSession: jest.fn()
295+
};
296+
mongoose.startSession.mockResolvedValue(mockSession);
297+
298+
req.body = { name: 'Transactional Project' };
299+
300+
await createProject(req, res);
301+
302+
expect(mockSession.startTransaction).toHaveBeenCalled();
303+
expect(mockSession.commitTransaction).toHaveBeenCalled();
304+
expect(mockSession.endSession).toHaveBeenCalled();
305+
expect(res.status).toHaveBeenCalledWith(201);
306+
expect(res.json).toHaveBeenCalledWith(
307+
expect.objectContaining({ success: true, message: 'Project created successfully' })
308+
);
309+
});
310+
});

0 commit comments

Comments
 (0)