Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"mode-watcher": "^0.3.0",
"pino": "^9.0.0",
"pino-pretty": "^11.0.0",
"slugify": "^1.6.6",
"svelte-sonner": "^0.3.24",
"sveltekit-superforms": "^2.13.0",
"tailwind-merge": "^2.3.0",
Expand Down
763 changes: 408 additions & 355 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion services/src/kysely/migrations/2024-04-28T09_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {

await createTableMigration(tx, 'projects')
.addColumn('name', 'text', (col) => col.unique().notNull())
.addColumn('base_language', 'integer', (col) =>
.addColumn('base_language_id', 'integer', (col) =>
col
.references('languages.id')
.onDelete('restrict')
.notNull()
.modifyEnd(sql`DEFERRABLE INITIALLY DEFERRED`)
)
.addColumn('slug', 'text', (col) => col.unique().notNull())
.execute()

await createTableMigration(tx, 'languages')
Expand Down
84 changes: 71 additions & 13 deletions services/src/project/project-repository.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createProject, getAllProjects } from './project-repository'
import {
checkProjectNameExists,
checkProjectSlugExists,
createProject,
getAllProjects
} from './project-repository'
import { runMigration } from '../db/database-migration-util'
import { db } from '../db/database'
import type { CreateProjectFormSchema, SelectableProject } from './project'
import type { ProjectCreationParams, SelectableProject } from './project'
import type { Languages } from 'kysely-codegen'
import type { Selectable } from 'kysely'

const projectCreationObject: CreateProjectFormSchema = {
const projectCreationObject: ProjectCreationParams = {
name: 'Test Project',
base_language: 'en'
base_language_code: 'en',
slug: 'test-project'
}

beforeEach(async () => {
Expand All @@ -29,7 +35,7 @@ describe('Project Repository', () => {
expect(project).toMatchObject({
id: createdProject.id,
name: projectCreationObject.name,
base_language: createdProject.base_language
base_language_id: createdProject.base_language_id
})

expect(project.id).toBeTypeOf('number')
Expand All @@ -44,6 +50,26 @@ describe('Project Repository', () => {
expect(projects).toHaveLength(1)
})

it('should not allow creation of projects with duplicate slugs', async () => {
const projectCreationObject1 = {
name: 'Test Project',
base_language_code: 'en',
slug: 'test-project'
}
const projectCreationObject2 = {
name: 'test-project',
base_language_code: 'en',
slug: 'test-project'
}

await createProject(projectCreationObject1)

await expect(createProject(projectCreationObject2)).rejects.toThrow()

const projects = await db.selectFrom('projects').selectAll().execute()
expect(projects).toHaveLength(1)
})

it('should create a base language for the project', async () => {
const createdProject = await createProject(projectCreationObject)

Expand All @@ -53,26 +79,26 @@ describe('Project Repository', () => {
const language = languages[0] as Selectable<Languages>

expect(language.project_id).toBe(createdProject.id)
expect(language.code).toBe(projectCreationObject.base_language)
expect(language.code).toBe(projectCreationObject.base_language_code)
})

it('should link the base language to the project', async () => {
const createdProject = await createProject(projectCreationObject)

expect(createdProject.base_language).not.toBe(0)
expect(createdProject.base_language_id).not.toBe(0)

const language = await db
.selectFrom('languages')
.where('id', '==', createdProject.base_language)
.where('id', '==', createdProject.base_language_id)
.selectAll()
.executeTakeFirstOrThrow()

expect(language.project_id).toBe(createdProject.id)
})

it('should allow creation of multiple projects with the same base language code', async () => {
const project1 = { name: 'Project 1', base_language: 'en' }
const project2 = { name: 'Project 2', base_language: 'en' }
const project1 = { name: 'Project 1', base_language_code: 'en', slug: 'project-1' }
const project2 = { name: 'Project 2', base_language_code: 'en', slug: 'project-2' }

await createProject(project1)
await createProject(project2)
Expand All @@ -95,8 +121,8 @@ describe('Project Repository', () => {
})

it('should return all created projects', async () => {
const project1 = { name: 'Project 1', base_language: 'en' }
const project2 = { name: 'Project 2', base_language: 'fr' }
const project1 = { name: 'Project 1', base_language_code: 'en', slug: 'project-1' }
const project2 = { name: 'Project 2', base_language_code: 'fr', slug: 'project-2' }

await createProject(project1)
await createProject(project2)
Expand All @@ -120,10 +146,42 @@ describe('Project Repository', () => {
expect(project).toMatchObject({
id: createdProject.id,
name: projectCreationObject.name,
base_language: createdProject.base_language
base_language_id: createdProject.base_language_id
})

expect(project.id).toBeTypeOf('number')
})
})

describe('checkProjectNameExists', () => {
it('should return true if a project with the given name exists', async () => {
await createProject(projectCreationObject)

const nameExists = await checkProjectNameExists(projectCreationObject.name)
expect(nameExists).toBe(true)
})

it('should return false if no project with the given name exists', async () => {
await createProject(projectCreationObject)

const nameExists = await checkProjectNameExists('Nonexistent Project')
expect(nameExists).toBe(false)
})
})

describe('checkProjectSlugExists', () => {
it('should return true if a project with the given slug exists', async () => {
await createProject(projectCreationObject)

const slugExists = await checkProjectSlugExists(projectCreationObject.slug)
expect(slugExists).toBe(true)
})

it('should return false if no project with the given slug exists', async () => {
await createProject(projectCreationObject)

const slugExists = await checkProjectSlugExists('nonexistent-slug')
expect(slugExists).toBe(false)
})
})
})
22 changes: 17 additions & 5 deletions services/src/project/project-repository.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import type { CreateProjectFormSchema, SelectableProject } from './project'
import type { ProjectCreationParams, SelectableProject } from './project'
import { db } from '../db/database'

export function createProject(project: CreateProjectFormSchema): Promise<SelectableProject> {
export function createProject(project: ProjectCreationParams): Promise<SelectableProject> {
return db.transaction().execute(async (tx) => {
const tempProject = await tx
.insertInto('projects')
.values({ name: project.name, base_language: 0 })
.values({ name: project.name, base_language_id: 0, slug: project.slug })
.returning('id')
.executeTakeFirstOrThrow(() => new Error('Error Creating Project'))

const baseLanguage = await tx
.insertInto('languages')
.values({ code: project.base_language, project_id: tempProject.id })
.values({ code: project.base_language_code, project_id: tempProject.id })
.returning('id')
.executeTakeFirstOrThrow(() => new Error('Error Creating Base Language'))

const createdProject = await tx
.updateTable('projects')
.set({ base_language: baseLanguage.id })
.set({ base_language_id: baseLanguage.id })
.where('id', '==', tempProject.id)
.returningAll()
.executeTakeFirstOrThrow(() => new Error('Error Updating Project'))
Expand All @@ -29,3 +29,15 @@ export function createProject(project: CreateProjectFormSchema): Promise<Selecta
export function getAllProjects(): Promise<SelectableProject[]> {
return db.selectFrom('projects').selectAll().execute()
}

export async function checkProjectNameExists(name: string) {
const result = await db.selectFrom('projects').selectAll().where('name', '==', name).execute()

return result.length > 0
}

export async function checkProjectSlugExists(slug: string) {
const result = await db.selectFrom('projects').selectAll().where('slug', '==', slug).execute()

return result.length > 0
}
24 changes: 22 additions & 2 deletions services/src/project/project-service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { CreateProjectFormSchema } from '$components/container/projects/create-project-schema'
import { createSlug } from '../util/slug/slug-service'
import { CreateProjectNameNotUniqueError } from '../error'
import { type CreateProjectFormSchema } from './project'
import * as repository from './project-repository'
import { SqliteError } from 'better-sqlite3'

export async function createProject(project: CreateProjectFormSchema) {
try {
return await repository.createProject(project)
const slug = createSlug(project.name)

return await repository.createProject({ ...project, slug })
} catch (e: unknown) {
if (e instanceof SqliteError && e.code === 'SQLITE_CONSTRAINT_UNIQUE') {
throw new CreateProjectNameNotUniqueError()
Expand All @@ -22,3 +25,20 @@ export async function getAllProjects() {
throw new Error('Error Getting Projects')
}
}

export async function checkProjectNameExists(name: string) {
try {
return await repository.checkProjectNameExists(name)
} catch (e) {
console.error(e)
throw new Error('Error Checking Project Name')
}
}

export async function checkProjectSlugExists(name: string) {
try {
return await repository.checkProjectSlugExists(createSlug(name))
} catch (e) {
throw new Error('Error Checking Project Slug')
}
}
35 changes: 31 additions & 4 deletions services/src/project/project-service.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createProject, getAllProjects } from './project-service'
import * as repository from './project-repository'
import type { CreateProjectFormSchema } from './project'
import { CreateProjectNameNotUniqueError } from '../error'
import { SqliteError } from 'better-sqlite3'
import type { CreateProjectFormSchema } from '$components/container/projects/create-project-schema'
import { createSlug } from '../util/slug/slug-service'

vi.mock('./project-repository', () => ({
createProject: vi.fn(),
getAllProjects: vi.fn()
}))

vi.mock('../util/slug/slug-service', () => ({
createSlug: vi.fn()
}))

const projectCreationObject: CreateProjectFormSchema = {
name: 'Test Project',
base_language: 'en'
base_language_code: 'en'
}

const mockSelectableProject = {
id: 1,
name: 'Test Project',
base_language: 1,
slug: 'test-project',
base_language_id: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}

beforeEach(() => {
vi.resetAllMocks()
vi.mocked(createSlug).mockReturnValue('test-project')
})

describe('Project Service', () => {
Expand All @@ -34,7 +41,11 @@ describe('Project Service', () => {

const project = await createProject(projectCreationObject)

expect(repository.createProject).toHaveBeenCalledWith(projectCreationObject)
expect(repository.createProject).toHaveBeenCalledWith({
...projectCreationObject,
slug: 'test-project'
})

expect(project).toEqual(mockSelectableProject)
})

Expand All @@ -55,6 +66,22 @@ describe('Project Service', () => {
new CreateProjectNameNotUniqueError()
)
})

it('should call the slug service to create a slug and use it to call repository', async () => {
const mockedSlug = 'ABCD'
vi.mocked(createSlug).mockReturnValue(mockedSlug)
vi.mocked(repository.createProject).mockResolvedValue(mockSelectableProject)

const project = await createProject(projectCreationObject)

expect(createSlug).toHaveBeenCalledWith(projectCreationObject.name)
expect(repository.createProject).toHaveBeenCalledWith({
...projectCreationObject,
slug: mockedSlug
})

expect(project).toEqual(mockSelectableProject)
})
})

describe('getAllProjects', () => {
Expand Down
18 changes: 5 additions & 13 deletions services/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
import type { Insertable, Selectable } from 'kysely'
import type { Projects } from 'kysely-codegen'
import { z } from 'zod'

export type ProjectCreationParams = Insertable<Omit<Projects, 'id' | 'created_at' | 'updated_at'>>
export type ProjectCreationParams = {
name: string
slug: string
base_language_code: string
}
export type Project = SelectableProject

export type SelectableProject = Selectable<Projects>
export type InsertableProject = Insertable<Projects>

export const createProjectSchema = z.object({
name: z
.string({ required_error: 'Project name is required' })
.min(1, 'Project name must have at least one character'),
base_language: z
.string({ required_error: 'Base language is required' })
.min(1, 'Base language must have at least one character')
})

export type CreateProjectFormSchema = z.infer<typeof createProjectSchema>
8 changes: 8 additions & 0 deletions services/src/util/slug/slug-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import slugify from 'slugify/slugify'

export function createSlug(text: string): string {
return slugify(text, {
lower: true,
strict: true
})
}
Loading