-
Notifications
You must be signed in to change notification settings - Fork 68
feat: Add clone project template functionality #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
90721f3
feat: Add clone project template functionality
yash-pouranik 0c9d78d
fix: resolve Mintlify CI docs.json seo error and clean response format
yash-pouranik ddbd6e1
fix(docs): include both google site verification codes in docs.json
yash-pouranik 51c6795
Merge branch 'feature/clone-template-fixes' into feature/clone-template
yash-pouranik 9e94756
Merge branch 'main' into feature/clone-template
yash-pouranik 4e53579
fix(dashboard-api): mock Mongoose in cloneTemplate test for reliable …
yash-pouranik ed2430c
fix(dashboard-api): expand transaction error matching for standalone …
yash-pouranik 4c40587
fix(dashboard-api): format template collection RLS object and update …
yash-pouranik dab9a3c
fix(dashboard-api): complete audit - rewrite cloneTemplate tests with…
yash-pouranik dc96277
fix(dashboard-api): apply PR review findings for createProject
yash-pouranik File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
310 changes: 310 additions & 0 deletions
310
apps/dashboard-api/src/__tests__/project.controller.cloneTemplate.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,310 @@ | ||
| 'use strict'; | ||
|
|
||
| const mongoose = require('mongoose'); | ||
|
|
||
| /** | ||
| * PROJECT_TEMPLATES — inline copy of just the templates createProject uses. | ||
| * We keep this in the test so we never pull in the real @urbackend/common | ||
| * (which initialises Redis, BullMQ, GC timers — all blow up in CI). | ||
| */ | ||
| const PROJECT_TEMPLATES = { | ||
| 'sdk-kanban': { | ||
| name: 'Kanban Board', | ||
| description: 'Full-featured Kanban board with drag-and-drop tasks.', | ||
| isAuthEnabled: true, | ||
| collections: [ | ||
| { | ||
| name: 'boards', | ||
| rls: 'private', | ||
| model: [{ key: 'title', type: 'String', required: true }], | ||
| }, | ||
| { | ||
| name: 'tasks', | ||
| rls: 'private', | ||
| model: [ | ||
| { key: 'boardId', type: 'Ref', required: true }, | ||
| { key: 'title', type: 'String', required: true }, | ||
| { key: 'status', type: 'String', required: true, default: 'todo' }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| }; | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* Real Zod — needed for schema validation inside createProject */ | ||
| /* ------------------------------------------------------------------ */ | ||
| const { z } = require('zod'); | ||
|
|
||
| /* Reconstruct the real createProjectSchema so parse() actually works */ | ||
| const createProjectSchema = z.object({ | ||
| name: z.string().min(1, 'Project name is required'), | ||
| description: z.string().optional(), | ||
| templateId: z.string().optional(), | ||
| siteUrl: z.preprocess( | ||
| (val) => (val === '' || val === null ? undefined : val), | ||
| z | ||
| .string() | ||
| .url('Invalid Site URL format') | ||
| .refine((url) => { | ||
| try { | ||
| const parsed = new URL(url); | ||
| return ( | ||
| parsed.protocol === 'https:' || | ||
| (parsed.protocol === 'http:' && | ||
| ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) | ||
| ); | ||
| } catch { | ||
| return false; | ||
| } | ||
| }, 'Site URL must use HTTPS (or http://localhost for local development)') | ||
| .optional(), | ||
| ), | ||
| }); | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* Mock @urbackend/common — NO jest.requireActual to avoid side- */ | ||
| /* effects (Redis, BullMQ queues, GC timers, email service, etc.) */ | ||
| /* ------------------------------------------------------------------ */ | ||
| const mockSave = jest.fn(); | ||
| const mockCountDocuments = jest.fn(); | ||
|
|
||
| // Minimal Project constructor mock that behaves like a Mongoose model | ||
| function MockProject(data) { | ||
| Object.assign(this, data); | ||
| this._id = data._id || new mongoose.Types.ObjectId(); | ||
| this.collections = this.collections || []; | ||
| this.isAuthEnabled = this.isAuthEnabled || false; | ||
| } | ||
| MockProject.prototype.save = mockSave; | ||
| MockProject.prototype.toObject = function () { | ||
| const obj = { ...this }; | ||
| // Simulate toObject stripping prototype methods | ||
| delete obj.save; | ||
| delete obj.toObject; | ||
| return obj; | ||
| }; | ||
| MockProject.countDocuments = mockCountDocuments; | ||
| // For spying / instanceof checks | ||
| MockProject.schema = { obj: {} }; | ||
|
|
||
| jest.mock('@urbackend/common', () => ({ | ||
| Project: MockProject, | ||
| PROJECT_TEMPLATES, | ||
| createProjectSchema, | ||
| generateApiKey: jest.fn(() => 'test_api_key'), | ||
| hashApiKey: jest.fn(() => 'hashed_key'), | ||
| encrypt: jest.fn(() => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })), | ||
| markDeveloperOnboardingStep: jest.fn().mockResolvedValue(), | ||
| AppError: class AppError extends Error { | ||
| constructor(statusCode, message, error = null) { | ||
| super(message); | ||
| this.name = 'AppError'; | ||
| this.statusCode = statusCode; | ||
| this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; | ||
| this.error = error || (statusCode >= 500 ? 'Internal Server Error' : 'Error'); | ||
| this.isOperational = true; | ||
| } | ||
| }, | ||
| // Stubs for other imports used at module-level by project.controller.js | ||
| Developer: { findById: jest.fn() }, | ||
| Log: { aggregate: jest.fn() }, | ||
| getStorage: jest.fn(), | ||
| getConnection: jest.fn(), | ||
| getCompiledModel: jest.fn(), | ||
| QueryEngine: jest.fn(), | ||
| storageRegistry: {}, | ||
| webhookQueue: { add: jest.fn() }, | ||
| enqueueCollectionCleanup: jest.fn(), | ||
| syncCollectionCleanup: jest.fn(), | ||
| resolveEffectivePlan: jest.fn(() => ({ name: 'free' })), | ||
| deleteProjectByApiKeyCache: jest.fn(), | ||
| setProjectById: jest.fn(), | ||
| getProjectById: jest.fn(), | ||
| deleteProjectById: jest.fn(), | ||
| isProjectStorageExternal: jest.fn(() => false), | ||
| getBucket: jest.fn(() => 'default'), | ||
| getPresignedUploadUrl: jest.fn(), | ||
| verifyUploadedFile: jest.fn(), | ||
| getPublicIp: jest.fn(), | ||
| clearCompiledModel: jest.fn(), | ||
| createUniqueIndexes: jest.fn(), | ||
| ApiAnalytics: { aggregate: jest.fn() }, | ||
| MailLog: { aggregate: jest.fn() }, | ||
| getProjectAccessQuery: jest.fn((userId) => ({ | ||
| $or: [{ owner: userId }, { 'members.user': userId }], | ||
| })), | ||
| getProjectRole: jest.fn(), | ||
| Invitation: { find: jest.fn() }, | ||
| sanitizeObjectId: jest.fn((v) => v), | ||
| sanitizeNonEmptyString: jest.fn((v) => v), | ||
| createCollectionSchema: { parse: jest.fn((v) => v) }, | ||
| editCollectionSchema: { parse: jest.fn((v) => v) }, | ||
| updateExternalConfigSchema: { parse: jest.fn((v) => v) }, | ||
| updateAuthProvidersSchema: { parse: jest.fn((v) => v) }, | ||
| decrypt: jest.fn(() => 'decrypted'), | ||
| getS3CompatibleStorage: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('../utils/emitEvent', () => ({ | ||
| emitEvent: jest.fn(), | ||
| })); | ||
|
|
||
| jest.setTimeout(15000); | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* Tests */ | ||
| /* ------------------------------------------------------------------ */ | ||
| describe('Project Controller - Clone Template', () => { | ||
| let req, res; | ||
|
|
||
| // Import AFTER mocks are set up | ||
| const { createProject } = require('../controllers/project.controller'); | ||
|
|
||
| beforeEach(() => { | ||
| req = { | ||
| body: {}, | ||
| user: { _id: new mongoose.Types.ObjectId(), isVerified: true }, | ||
| }; | ||
| res = { | ||
| status: jest.fn().mockReturnThis(), | ||
| json: jest.fn(), | ||
| }; | ||
| jest.clearAllMocks(); | ||
|
|
||
| // Simulate standalone MongoDB (no replica set) — triggers fallback path | ||
| jest.spyOn(mongoose, 'startSession').mockRejectedValue( | ||
| Object.assign(new Error('Transaction numbers are only allowed on a replica set member or mongos'), { code: 20 }) | ||
| ); | ||
|
|
||
| // Mock save to behave like Mongoose save | ||
| mockSave.mockImplementation(function () { | ||
| this._id = this._id || new mongoose.Types.ObjectId(); | ||
| return Promise.resolve(this); | ||
| }); | ||
|
|
||
| mockCountDocuments.mockResolvedValue(0); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it('should create a project with template configuration', async () => { | ||
| req.body = { | ||
| name: 'Test Clone Project', | ||
| templateId: 'sdk-kanban', | ||
| }; | ||
|
|
||
| await createProject(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(201); | ||
| expect(res.json).toHaveBeenCalled(); | ||
|
|
||
| const responseObj = res.json.mock.calls[0][0]; | ||
| expect(responseObj.success).toBe(true); | ||
| expect(responseObj.data.name).toBe('Test Clone Project'); | ||
| expect(responseObj.data.isAuthEnabled).toBe(true); | ||
|
|
||
| const collectionNames = responseObj.data.collections.map((c) => c.name); | ||
| expect(collectionNames).toContain('boards'); | ||
| expect(collectionNames).toContain('tasks'); | ||
| expect(collectionNames).toContain('users'); | ||
|
|
||
| const boardsCol = responseObj.data.collections.find((c) => c.name === 'boards'); | ||
| const mode = typeof boardsCol.rls === 'string' ? boardsCol.rls : boardsCol.rls?.mode; | ||
| expect(mode).toBe('private'); | ||
| }); | ||
|
|
||
| it('should ignore invalid templateId and create a normal project', async () => { | ||
| req.body = { | ||
| name: 'Normal Project', | ||
| templateId: 'invalid-template', | ||
| }; | ||
|
|
||
| await createProject(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(201); | ||
| const responseObj = res.json.mock.calls[0][0]; | ||
| expect(responseObj.success).toBe(true); | ||
| expect(responseObj.data.name).toBe('Normal Project'); | ||
| expect(responseObj.data.collections).toHaveLength(0); | ||
| expect(responseObj.data.isAuthEnabled).toBeFalsy(); | ||
| }); | ||
|
|
||
| it('should create a project without templateId', async () => { | ||
| req.body = { | ||
| name: 'Blank Project', | ||
| }; | ||
|
|
||
| await createProject(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(201); | ||
| const responseObj = res.json.mock.calls[0][0]; | ||
| expect(responseObj.success).toBe(true); | ||
| expect(responseObj.data.name).toBe('Blank Project'); | ||
| expect(responseObj.data.collections).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('should return 400 for missing project name', async () => { | ||
| req.body = {}; | ||
|
|
||
| await createProject(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(400); | ||
| }); | ||
|
|
||
| it('should enforce project limit when set', async () => { | ||
| req.body = { name: 'Over Limit' }; | ||
| req.projectLimit = 2; | ||
| mockCountDocuments.mockResolvedValue(2); | ||
|
|
||
| await createProject(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(403); | ||
| }); | ||
|
|
||
| it('should add users collection with auth-enabled template', async () => { | ||
| req.body = { | ||
| name: 'Auth Template Project', | ||
| templateId: 'sdk-kanban', | ||
| }; | ||
|
|
||
| await createProject(req, res); | ||
|
|
||
| expect(res.status).toHaveBeenCalledWith(201); | ||
| const responseObj = res.json.mock.calls[0][0]; | ||
| const usersCol = responseObj.data.collections.find((c) => c.name === 'users'); | ||
| expect(usersCol).toBeDefined(); | ||
|
|
||
| const usersMode = typeof usersCol.rls === 'string' ? usersCol.rls : usersCol.rls?.mode; | ||
| expect(usersMode).toBe('private'); | ||
|
|
||
| const emailField = usersCol.model.find((f) => f.key === 'email'); | ||
| const passwordField = usersCol.model.find((f) => f.key === 'password'); | ||
| expect(emailField).toBeDefined(); | ||
| expect(passwordField).toBeDefined(); | ||
| }); | ||
|
|
||
| it('should create project successfully using transactional path', async () => { | ||
| const mockSession = { | ||
| startTransaction: jest.fn(), | ||
| commitTransaction: jest.fn().mockResolvedValue(), | ||
| abortTransaction: jest.fn().mockResolvedValue(), | ||
| endSession: jest.fn() | ||
| }; | ||
| mongoose.startSession.mockResolvedValue(mockSession); | ||
|
|
||
| req.body = { name: 'Transactional Project' }; | ||
|
|
||
| await createProject(req, res); | ||
|
|
||
| expect(mockSession.startTransaction).toHaveBeenCalled(); | ||
| expect(mockSession.commitTransaction).toHaveBeenCalled(); | ||
| expect(mockSession.endSession).toHaveBeenCalled(); | ||
| expect(res.status).toHaveBeenCalledWith(201); | ||
| expect(res.json).toHaveBeenCalledWith( | ||
| expect.objectContaining({ success: true, message: 'Project created successfully' }) | ||
| ); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Fully re-implemented mocks risk silent drift from production contracts.
PROJECT_TEMPLATES,createProjectSchema, andAppErrorare hand-duplicated here instead of pulled from@urbackend/common. If the realpackages/common/src/utils/templates.js,input.validation.js, or the realAppErrorclass change, these tests keep passing against stale copies while production behavior silently diverges — defeating the purpose of the "Template creation validation" coverage this PR adds.Since the side-effecting parts live behind the package's index (Redis/BullMQ/GC timers), consider requiring the specific submodules directly instead of hand-copying their contents:
♻️ Suggested approach
Then reference these real exports inside the
jest.mock('@urbackend/common', ...)factory instead of the local re-implementations, keeping only the heavy/side-effecting stubs (Redis, BullMQ, GCS, etc.) mocked.🤖 Prompt for AI Agents