diff --git a/apps/api/src/common/slug-generator.spec.ts b/apps/api/src/common/slug-generator.spec.ts new file mode 100644 index 00000000..a2921839 --- /dev/null +++ b/apps/api/src/common/slug-generator.spec.ts @@ -0,0 +1,113 @@ +import { PrismaService } from '@/prisma/prisma.service' +import generateEntitySlug, { + generateSlugName, + incrementSlugSuffix +} from './slug-generator' +import { mockDeep } from 'jest-mock-extended' + +describe('generateEntitySlug', () => { + let prisma + + beforeEach(() => { + prisma = mockDeep() + }) + + describe('generateSlugName', () => { + it('should convert name to slug format', () => { + expect(generateSlugName('Hello World')).toBe('hello-world') + expect(generateSlugName('Entity with 123')).toBe('entity-with-123') + expect(generateSlugName('Special #Name!')).toBe('special-name') + }) + }) + + describe('incrementSlugSuffix', () => { + it('should return base slug with `-0` when no suffix is found', () => { + const result = incrementSlugSuffix('', 'my-slug') + expect(result).toBe('my-slug-0') + }) + + it('should increment suffix when found', () => { + const result = incrementSlugSuffix('my-slug-0', 'my-slug') + expect(result).toBe('my-slug-1') + }) + + it('should handle complex increment cases with carryover', () => { + const result = incrementSlugSuffix('my-slug-z', 'my-slug') + expect(result).toBe('my-slug-00') + }) + }) + + describe('generateEntitySlug for each entity type', () => { + it('should generate a unique slug for WORKSPACE_ROLE', async () => { + prisma.$queryRaw.mockResolvedValue([ + { + slug: 'workspace-role-0' + } + ]) + + const slug = await generateEntitySlug( + 'Workspace Role', + 'WORKSPACE_ROLE', + prisma + ) + expect(slug).toBe('workspace-role-1') + }) + + it('should generate a unique slug for WORKSPACE', async () => { + prisma.$queryRaw.mockResolvedValue([]) + + const slug = await generateEntitySlug('Workspace', 'WORKSPACE', prisma) + expect(slug).toBe('workspace-0') + }) + + it('should generate a unique slug for PROJECT', async () => { + prisma.$queryRaw.mockResolvedValue([{ slug: 'project-z' }]) + + const slug = await generateEntitySlug('Project', 'PROJECT', prisma) + expect(slug).toBe('project-00') + }) + + it('should generate a unique slug for VARIABLE', async () => { + prisma.$queryRaw.mockResolvedValue([{ slug: 'variable-az' }]) + + const slug = await generateEntitySlug('Variable', 'VARIABLE', prisma) + expect(slug).toBe('variable-b0') + }) + + it('should generate a unique slug for SECRET', async () => { + prisma.$queryRaw.mockResolvedValue([{ slug: 'secret-9' }]) + + const slug = await generateEntitySlug('Secret', 'SECRET', prisma) + expect(slug).toBe('secret-a') + }) + + it('should generate a unique slug for INTEGRATION', async () => { + prisma.$queryRaw.mockResolvedValue([{ slug: 'integration-b' }]) + + const slug = await generateEntitySlug( + 'Integration', + 'INTEGRATION', + prisma + ) + expect(slug).toBe('integration-c') + }) + + it('should generate a unique slug for ENVIRONMENT', async () => { + prisma.$queryRaw.mockResolvedValue([{ slug: 'environment-zz' }]) + + const slug = await generateEntitySlug( + 'Environment', + 'ENVIRONMENT', + prisma + ) + expect(slug).toBe('environment-000') + }) + + it('should generate a unique slug for API_KEY', async () => { + prisma.$queryRaw.mockResolvedValue([{ slug: 'api-key-09' }]) + + const slug = await generateEntitySlug('Api @Key', 'API_KEY', prisma) + expect(slug).toBe('api-key-0a') + }) + }) +}) diff --git a/apps/api/src/common/slug-generator.ts b/apps/api/src/common/slug-generator.ts index 2402c485..eca3f47d 100644 --- a/apps/api/src/common/slug-generator.ts +++ b/apps/api/src/common/slug-generator.ts @@ -1,14 +1,59 @@ import { PrismaService } from '@/prisma/prisma.service' import { Workspace } from '@prisma/client' +export const incrementSlugSuffix = ( + existingSlug: string, + baseSlug: string +): string => { + const charset = '0123456789abcdefghijklmnopqrstuvwxyz' + + let suffix = '' + + if (existingSlug) { + suffix = existingSlug.substring(baseSlug.length + 1) + } + + if (!suffix) { + return `${baseSlug}-0` + } + + let result = '' + let carry = true + + for (let i = suffix.length - 1; i >= 0; i--) { + if (carry) { + const currentChar = suffix[i] + const index = charset.indexOf(currentChar) + + if (index === -1) { + throw new Error(`Invalid character in slug suffix: ${currentChar}`) + } + const nextIndex = (index + 1) % charset.length + result = charset[nextIndex] + result + + // Carry over if we wrapped around to '0' + carry = nextIndex === 0 + } else { + // No carry, just append the remaining part of the suffix + result = suffix[i] + result + } + } + + if (carry) { + result = '0' + result + } + + return `${baseSlug}-${result}` +} + /** - * Generates a unique slug for the given name. It keeps generating slugs until it finds + * Generates a slug for the given name. It keeps generating slugs until it finds * one that does not exist in the database. * * @param name The name of the entity. - * @returns A unique slug for the given entity. + * @returns A alphanumeric slug for the given name. */ -const generateSlug = (name: string): string => { +export const generateSlugName = (name: string): string => { // Convert to lowercase const lowerCaseName = name.trim().toLowerCase() @@ -16,78 +61,140 @@ const generateSlug = (name: string): string => { const hyphenatedName = lowerCaseName.replace(/\s+/g, '-') // Replace all non-alphanumeric characters with hyphens - const alphanumericName = hyphenatedName.replace(/[^a-zA-Z0-9-]/g, '-') + const alphanumericName = hyphenatedName.replace(/[^a-zA-Z0-9-]/g, '') - // Append the name with 5 alphanumeric characters - const slug = - alphanumericName + '-' + Math.random().toString(36).substring(2, 7) - return slug + return alphanumericName } -const checkWorkspaceRoleSlugExists = async ( +const getWorkspaceRoleIfSlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.workspaceRole.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug: { slug: string }[] = await prisma.$queryRaw< + { slug: string }[] + >` + SELECT slug + FROM "WorkspaceRole" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } -const checkWorkspaceSlugExists = async ( +const getWorkspaceSlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.workspace.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug = await prisma.$queryRaw<{ slug: string }[]>` + SELECT slug + FROM "Workspace" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } -const checkProjectSlugExists = async ( +const getProjectSlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.project.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug = await prisma.$queryRaw<{ slug: string }[]>` + SELECT slug + FROM "Project" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } -const checkVariableSlugExists = async ( +const getVariableSlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.variable.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug = await prisma.$queryRaw<{ slug: string }[]>` + SELECT slug + FROM "Variable" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } -const checkSecretSlugExists = async ( +const getSecretSlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.secret.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug = await prisma.$queryRaw<{ slug: string }[]>` + SELECT slug + FROM "Secret" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } -const checkIntegrationSlugExists = async ( +const getIntegrationSlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.integration.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug = await prisma.$queryRaw<{ slug: string }[]>` + SELECT slug + FROM "Integration" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } -const checkEnvironmentSlugExists = async ( +const getEnvironmentSlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.environment.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug = await prisma.$queryRaw<{ slug: string }[]>` + SELECT slug + FROM "Environment" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } -const checkApiKeySlugExists = async ( +const getApiKeySlugExists = async ( slug: Workspace['slug'], prisma: PrismaService -): Promise => { - return (await prisma.apiKey.count({ where: { slug } })) > 0 +): Promise => { + const search = `${slug}-[a-z0-9]*` + const existingSlug = await prisma.$queryRaw<{ slug: string }[]>` + SELECT slug + FROM "ApiKey" + WHERE slug ~ ${search} + ORDER BY slug DESC + LIMIT 1 + ` + return existingSlug.length > 0 ? existingSlug[0].slug : '' } /** - * Generates a unique slug for the given entity type and name. It keeps - * generating slugs until it finds one that does not exist in the database. + * Generates a slug for the given name and entity type. It keeps generating slugs until it finds + * one that does not exist in the database. * * @param name The name of the entity. * @param entityType The type of the entity. - * @param prisma The Prisma client to use to check the existence of the slug. - * @returns A unique slug for the given entity. + * @returns A alphanumeric slug for the given name. */ export default async function generateEntitySlug( name: string, @@ -102,49 +209,33 @@ export default async function generateEntitySlug( | 'API_KEY', prisma: PrismaService ): Promise { - while (true) { - const slug = generateSlug(name) - switch (entityType) { - case 'WORKSPACE_ROLE': - if (await checkWorkspaceRoleSlugExists(slug, prisma)) { - continue - } - return slug - case 'WORKSPACE': - if (await checkWorkspaceSlugExists(slug, prisma)) { - continue - } - return slug - case 'PROJECT': - if (await checkProjectSlugExists(slug, prisma)) { - continue - } - return slug - case 'VARIABLE': - if (await checkVariableSlugExists(slug, prisma)) { - continue - } - return slug - case 'SECRET': - if (await checkSecretSlugExists(slug, prisma)) { - continue - } - return slug - case 'INTEGRATION': - if (await checkIntegrationSlugExists(slug, prisma)) { - continue - } - return slug - case 'ENVIRONMENT': - if (await checkEnvironmentSlugExists(slug, prisma)) { - continue - } - return slug - case 'API_KEY': - if (await checkApiKeySlugExists(slug, prisma)) { - continue - } - return slug - } + const baseSlug = generateSlugName(name) + let existingSlug = '' + switch (entityType) { + case 'WORKSPACE_ROLE': + existingSlug = await getWorkspaceRoleIfSlugExists(baseSlug, prisma) + break + case 'WORKSPACE': + existingSlug = await getWorkspaceSlugExists(baseSlug, prisma) + break + case 'PROJECT': + existingSlug = await getProjectSlugExists(baseSlug, prisma) + break + case 'VARIABLE': + existingSlug = await getVariableSlugExists(baseSlug, prisma) + break + case 'SECRET': + existingSlug = await getSecretSlugExists(baseSlug, prisma) + break + case 'INTEGRATION': + existingSlug = await getIntegrationSlugExists(baseSlug, prisma) + break + case 'ENVIRONMENT': + existingSlug = await getEnvironmentSlugExists(baseSlug, prisma) + break + case 'API_KEY': + existingSlug = await getApiKeySlugExists(baseSlug, prisma) + break } + return incrementSlugSuffix(existingSlug, baseSlug) } diff --git a/apps/api/src/project/project.e2e.spec.ts b/apps/api/src/project/project.e2e.spec.ts index 88b81f49..8aa49e30 100644 --- a/apps/api/src/project/project.e2e.spec.ts +++ b/apps/api/src/project/project.e2e.spec.ts @@ -852,12 +852,16 @@ describe('Project Controller Tests', () => { ) // Add user to workspace as a member - await workspaceMembershipService.inviteUsersToWorkspace(user1, workspace1.slug, [ - { - email: johnny.email, - roleSlugs: [role.slug] - } - ]) + await workspaceMembershipService.inviteUsersToWorkspace( + user1, + workspace1.slug, + [ + { + email: johnny.email, + roleSlugs: [role.slug] + } + ] + ) // Accept the invitation on behalf of the user await workspaceMembershipService.acceptInvitation(johnny, workspace1.slug)