Skip to content

Commit

Permalink
soft-delete and restore projects
Browse files Browse the repository at this point in the history
  • Loading branch information
ruggi committed Feb 12, 2024
1 parent 8b994cd commit 636223b
Show file tree
Hide file tree
Showing 10 changed files with 345 additions and 15 deletions.
124 changes: 121 additions & 3 deletions utopia-remix/app/models/project.server.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import moment from 'moment'
import { prisma } from '../db.server'
import { createTestProject, truncateTables } from '../test-util'
import { listProjects } from './project.server'
import { createTestProject, createTestUser, truncateTables } from '../test-util'
import {
listProjects,
renameProject,
restoreDeletedProject,
softDeleteProject,
} from './project.server'

describe('project model', () => {
afterEach(async () => {
// cleanup
await truncateTables([prisma.projectID, prisma.project])
await truncateTables([prisma.projectID, prisma.project, prisma.userDetails])
})

describe('listProjects', () => {
Expand Down Expand Up @@ -60,6 +65,119 @@ describe('project model', () => {
const aliceProjects = await listProjects({ ownerId: 'alice' })
expect(aliceProjects.map((p) => p.proj_id)).toEqual(['baz'])
})

it('ignores soft-deleted projects', async () => {
await createTestProject(prisma, { id: 'foo', ownerId: 'bob' })
await createTestProject(prisma, { id: 'bar', ownerId: 'bob', deleted: true })
await createTestProject(prisma, { id: 'baz', ownerId: 'bob' })

const bobProjects = await listProjects({ ownerId: 'bob' })
expect(bobProjects.length).toBe(2)
expect(bobProjects.map((p) => p.proj_id)).toEqual(['baz', 'foo'])
})
})
})

describe('renameProject', () => {
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestProject(prisma, { id: 'foo', ownerId: 'bob', title: 'the-project' })
await createTestProject(prisma, {
id: 'deleted-project',
ownerId: 'bob',
title: 'the-project',
deleted: true,
})
})
it('requires the user', async () => {
const fn = async () => renameProject({ id: 'foo', userId: 'JOHN-DOE', title: 'test' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project', async () => {
const fn = async () => renameProject({ id: 'bar', userId: 'bob', title: 'test' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project ownership', async () => {
const fn = async () => renameProject({ id: 'foo', userId: 'alice', title: 'test' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project not to be soft-deleted', async () => {
const fn = async () => renameProject({ id: 'deleted-project', userId: 'bob', title: 'test' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('renames the project', async () => {
await renameProject({ id: 'foo', userId: 'bob', title: 'renamed' })
const got = await prisma.project.findFirst({ where: { proj_id: 'foo' } })
expect(got?.title).toEqual('renamed')
})
})

describe('softDeleteProject', () => {
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestProject(prisma, { id: 'foo', ownerId: 'bob' })
await createTestProject(prisma, {
id: 'deleted-project',
ownerId: 'bob',
deleted: true,
})
})
it('requires the user', async () => {
const fn = async () => softDeleteProject({ id: 'foo', userId: 'JOHN-DOE' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project', async () => {
const fn = async () => softDeleteProject({ id: 'bar', userId: 'bob' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project ownership', async () => {
const fn = async () => softDeleteProject({ id: 'foo', userId: 'alice' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project not to be soft-deleted', async () => {
const fn = async () => softDeleteProject({ id: 'deleted-project', userId: 'bob' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('soft-deletes the project', async () => {
await softDeleteProject({ id: 'foo', userId: 'bob' })
const got = await prisma.project.findFirst({ where: { proj_id: 'foo' } })
expect(got?.deleted).toEqual(true)
})
})

describe('restoreDeletedProject', () => {
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestProject(prisma, { id: 'foo', ownerId: 'bob' })
await createTestProject(prisma, {
id: 'deleted-project',
ownerId: 'bob',
deleted: true,
})
})
it('requires the user', async () => {
const fn = async () => restoreDeletedProject({ id: 'foo', userId: 'JOHN-DOE' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project', async () => {
const fn = async () => restoreDeletedProject({ id: 'bar', userId: 'bob' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project ownership', async () => {
const fn = async () => restoreDeletedProject({ id: 'foo', userId: 'alice' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires the project to be soft-deleted', async () => {
const fn = async () => restoreDeletedProject({ id: 'foo', userId: 'bob' })
await expect(fn).rejects.toThrow('Record to update not found')
})
it('restores the project', async () => {
await restoreDeletedProject({ id: 'deleted-project', userId: 'bob' })
const got = await prisma.project.findFirst({ where: { proj_id: 'foo' } })
expect(got?.deleted).toEqual(null)
})
})
})
33 changes: 31 additions & 2 deletions utopia-remix/app/models/project.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ const selectProjectWithoutContent: Record<keyof ProjectWithoutContent, true> = {
export async function listProjects(params: { ownerId: string }): Promise<ProjectWithoutContent[]> {
return prisma.project.findMany({
select: selectProjectWithoutContent,
where: { owner_id: params.ownerId },
where: {
owner_id: params.ownerId,
OR: [{ deleted: null }, { deleted: false }],
},
orderBy: { modified_at: 'desc' },
})
}
Expand All @@ -27,8 +30,34 @@ export async function renameProject(params: {
title: string
}): Promise<ProjectWithoutContent> {
return prisma.project.update({
where: { proj_id: params.id, owner_id: params.userId },
where: {
proj_id: params.id,
owner_id: params.userId,
OR: [{ deleted: null }, { deleted: false }],
},
data: { title: params.title },
select: selectProjectWithoutContent,
})
}

export async function softDeleteProject(params: { id: string; userId: string }): Promise<void> {
await prisma.project.update({
where: {
proj_id: params.id,
owner_id: params.userId,
OR: [{ deleted: null }, { deleted: false }],
},
data: { deleted: true },
})
}

export async function restoreDeletedProject(params: { id: string; userId: string }): Promise<void> {
await prisma.project.update({
where: {
proj_id: params.id,
owner_id: params.userId,
deleted: true,
},
data: { deleted: null },
})
}
68 changes: 68 additions & 0 deletions utopia-remix/app/routes-test/projects.$id.delete.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { prisma } from '../db.server'
import { handleDeleteProject } from '../routes/projects.$id.delete'
import {
createTestProject,
createTestSession,
createTestUser,
newTestRequest,
truncateTables,
} from '../test-util'
import { ApiError } from '../util/api.server'

describe('handleDeleteProject', () => {
afterEach(async () => {
await truncateTables([
prisma.userDetails,
prisma.persistentSession,
prisma.project,
prisma.projectID,
])
})

beforeEach(async () => {
await createTestUser(prisma, { id: 'foo' })
await createTestUser(prisma, { id: 'bar' })
await createTestSession(prisma, { key: 'the-key', userId: 'foo' })
await createTestProject(prisma, { id: 'one', ownerId: 'foo', title: 'project-one' })
await createTestProject(prisma, { id: 'two', ownerId: 'bar', title: 'project-two' })
})

it('requires a user', async () => {
const fn = async () =>
handleDeleteProject(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {})
await expect(fn).rejects.toThrow(ApiError)
await expect(fn).rejects.toThrow('session not found')
})
it('requires a valid id', async () => {
const fn = async () =>
handleDeleteProject(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {})
await expect(fn).rejects.toThrow(ApiError)
await expect(fn).rejects.toThrow('id is null')
})
it('requires a valid project', async () => {
const fn = async () => {
const req = newTestRequest({ method: 'POST', authCookie: 'the-key' })
return handleDeleteProject(req, { id: 'doesnt-exist' })
}

await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires ownership of the project', async () => {
const fn = async () => {
const req = newTestRequest({ method: 'POST', authCookie: 'the-key' })
return handleDeleteProject(req, { id: 'two' })
}

await expect(fn).rejects.toThrow('Record to update not found')
})
it('soft-deletes the project', async () => {
const fn = async () => {
const req = newTestRequest({ method: 'POST', authCookie: 'the-key' })
return handleDeleteProject(req, { id: 'one' })
}

await fn()
const project = await prisma.project.findFirst({ where: { proj_id: 'one' } })
expect(project?.deleted).toEqual(true)
})
})
5 changes: 0 additions & 5 deletions utopia-remix/app/routes-test/projects.$id.rename.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,6 @@ describe('handleRenameProject', () => {
await createTestProject(prisma, { id: 'two', ownerId: 'bar', title: 'project-two' })
})

it('requires a POST method', async () => {
const fn = async () => handleRenameProject(newTestRequest(), {})
await expect(fn).rejects.toThrow(ApiError)
await expect(fn).rejects.toThrow('invalid method')
})
it('requires a user', async () => {
const fn = async () =>
handleRenameProject(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {})
Expand Down
81 changes: 81 additions & 0 deletions utopia-remix/app/routes-test/projects.$id.restore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { prisma } from '../db.server'
import { handleRestoreDeletedProject } from '../routes/projects.$id.restore'
import {
createTestProject,
createTestSession,
createTestUser,
newTestRequest,
truncateTables,
} from '../test-util'
import { ApiError } from '../util/api.server'

describe('handleRestoreDeletedProject', () => {
afterEach(async () => {
await truncateTables([
prisma.userDetails,
prisma.persistentSession,
prisma.project,
prisma.projectID,
])
})

beforeEach(async () => {
await createTestUser(prisma, { id: 'foo' })
await createTestUser(prisma, { id: 'bar' })
await createTestSession(prisma, { key: 'the-key', userId: 'foo' })
await createTestProject(prisma, {
id: 'one',
ownerId: 'foo',
title: 'project-one',
deleted: true,
})
await createTestProject(prisma, { id: 'two', ownerId: 'foo', title: 'project-two' })
})

it('requires a user', async () => {
const fn = async () =>
handleRestoreDeletedProject(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {})
await expect(fn).rejects.toThrow(ApiError)
await expect(fn).rejects.toThrow('session not found')
})
it('requires a valid id', async () => {
const fn = async () =>
handleRestoreDeletedProject(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {})
await expect(fn).rejects.toThrow(ApiError)
await expect(fn).rejects.toThrow('id is null')
})
it('requires a valid project', async () => {
const fn = async () => {
const req = newTestRequest({ method: 'POST', authCookie: 'the-key' })
return handleRestoreDeletedProject(req, { id: 'doesnt-exist' })
}

await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires ownership of the project', async () => {
const fn = async () => {
const req = newTestRequest({ method: 'POST', authCookie: 'the-key' })
return handleRestoreDeletedProject(req, { id: 'two' })
}

await expect(fn).rejects.toThrow('Record to update not found')
})
it('requires a deleted project', async () => {
const fn = async () => {
const req = newTestRequest({ method: 'POST', authCookie: 'the-key' })
return handleRestoreDeletedProject(req, { id: 'two' })
}

await expect(fn).rejects.toThrow('Record to update not found')
})
it('restores the project', async () => {
const fn = async () => {
const req = newTestRequest({ method: 'POST', authCookie: 'the-key' })
return handleRestoreDeletedProject(req, { id: 'one' })
}

await fn()
const project = await prisma.project.findFirst({ where: { proj_id: 'one' } })
expect(project?.deleted).toEqual(null)
})
})
20 changes: 20 additions & 0 deletions utopia-remix/app/routes/projects.$id.delete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ActionFunctionArgs } from '@remix-run/node'
import { Params } from '@remix-run/react'
import { softDeleteProject } from '../models/project.server'
import { ensure, handle, requireUser } from '../util/api.server'
import { Status } from '../util/statusCodes.server'

export async function action(args: ActionFunctionArgs) {
return handle(args, { POST: handleDeleteProject })
}

export async function handleDeleteProject(req: Request, params: Params<string>) {
const user = await requireUser(req)

const { id } = params
ensure(id != null, 'id is null', Status.BAD_REQUEST)

await softDeleteProject({ id: id, userId: user.user_id })

return {}
}
2 changes: 0 additions & 2 deletions utopia-remix/app/routes/projects.$id.rename.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ export async function action(args: ActionFunctionArgs) {
}

export async function handleRenameProject(req: Request, params: Params<string>) {
ensure(req.method === 'POST', 'invalid method', Status.METHOD_NOT_ALLOWED)

const user = await requireUser(req)

const { id } = params
Expand Down
Loading

0 comments on commit 636223b

Please sign in to comment.