diff --git a/utopia-remix/app/models/project.server.spec.ts b/utopia-remix/app/models/project.server.spec.ts index ab540c3ac956..e40463acf86f 100644 --- a/utopia-remix/app/models/project.server.spec.ts +++ b/utopia-remix/app/models/project.server.spec.ts @@ -2,6 +2,8 @@ import moment from 'moment' import { prisma } from '../db.server' import { createTestProject, createTestUser, truncateTables } from '../test-util' import { + hardDeleteAllProjects, + hardDeleteProject, listDeletedProjects, listProjects, renameProject, @@ -213,4 +215,61 @@ describe('project model', () => { }) }) }) + + describe('hardDeleteProject', () => { + 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 () => hardDeleteProject({ id: 'foo', userId: 'JOHN-DOE' }) + await expect(fn).rejects.toThrow('Record to delete does not exist') + }) + it('requires the project', async () => { + const fn = async () => hardDeleteProject({ id: 'bar', userId: 'bob' }) + await expect(fn).rejects.toThrow('Record to delete does not exist') + }) + it('requires the project ownership', async () => { + const fn = async () => hardDeleteProject({ id: 'foo', userId: 'alice' }) + await expect(fn).rejects.toThrow('Record to delete does not exist') + }) + it('requires the project to be soft-deleted', async () => { + const fn = async () => hardDeleteProject({ id: 'foo', userId: 'bob' }) + await expect(fn).rejects.toThrow('Record to delete does not exist') + }) + it('hard-deletes the project', async () => { + const existing = await prisma.project.count({ where: { proj_id: 'deleted-project' } }) + expect(existing).toEqual(1) + await hardDeleteProject({ id: 'deleted-project', userId: 'bob' }) + const got = await prisma.project.count({ where: { proj_id: 'deleted-project' } }) + expect(got).toEqual(0) + }) + }) + + describe('hardDeleteAllProjects', () => { + beforeEach(async () => { + await createTestUser(prisma, { id: 'bob' }) + await createTestUser(prisma, { id: 'alice' }) + await createTestProject(prisma, { id: 'one', ownerId: 'bob' }) + await createTestProject(prisma, { id: 'two', ownerId: 'bob', deleted: true }) + await createTestProject(prisma, { id: 'three', ownerId: 'bob', deleted: true }) + await createTestProject(prisma, { id: 'four', ownerId: 'alice', deleted: true }) + await createTestProject(prisma, { id: 'five', ownerId: 'bob' }) + await createTestProject(prisma, { id: 'six', ownerId: 'bob', deleted: true }) + await createTestProject(prisma, { id: 'seven', ownerId: 'alice' }) + }) + it('hard-deletes all soft-deleted project owned by the user', async () => { + await hardDeleteAllProjects({ userId: 'bob' }) + const bobProjects = await prisma.project.findMany({ where: { owner_id: 'bob' } }) + expect(bobProjects.map((p) => p.proj_id)).toEqual(['one', 'five']) + const aliceProjects = await prisma.project.findMany({ where: { owner_id: 'alice' } }) + expect(aliceProjects.map((p) => p.proj_id)).toEqual(['four', 'seven']) + }) + }) }) diff --git a/utopia-remix/app/routes-test/projects.$id.destroy.spec.ts b/utopia-remix/app/routes-test/projects.$id.destroy.spec.ts new file mode 100644 index 000000000000..13974c6c4a5d --- /dev/null +++ b/utopia-remix/app/routes-test/projects.$id.destroy.spec.ts @@ -0,0 +1,91 @@ +import { prisma } from '../db.server' +import { handleDestroyProject } from '../routes/projects.$id.destroy' +import { + createTestProject, + createTestSession, + createTestUser, + newTestRequest, + truncateTables, +} from '../test-util' +import { ApiError } from '../util/api.server' + +describe('handleDestroyProject', () => { + 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: 'foo', + title: 'project-two', + deleted: true, + }) + await createTestProject(prisma, { + id: 'three', + ownerId: 'bar', + title: 'project-three', + deleted: true, + }) + }) + + it('requires a user', async () => { + const fn = async () => + handleDestroyProject(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 () => + handleDestroyProject(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 handleDestroyProject(req, { id: 'doesnt-exist' }) + } + + await expect(fn).rejects.toThrow('Record to delete does not exist') + }) + it('requires ownership of the project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleDestroyProject(req, { id: 'three' }) + } + + await expect(fn).rejects.toThrow('Record to delete does not exist') + }) + it('requires soft-deletion of the project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleDestroyProject(req, { id: 'one' }) + } + + await expect(fn).rejects.toThrow('Record to delete does not exist') + }) + it('hard-deletes the project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleDestroyProject(req, { id: 'two' }) + } + + await fn() + const got = await prisma.project.count({ where: { proj_id: 'two' } }) + expect(got).toEqual(0) + }) +}) diff --git a/utopia-remix/app/routes-test/projects.destroy.spec.ts b/utopia-remix/app/routes-test/projects.destroy.spec.ts new file mode 100644 index 000000000000..cf8c2eb49768 --- /dev/null +++ b/utopia-remix/app/routes-test/projects.destroy.spec.ts @@ -0,0 +1,50 @@ +import { prisma } from '../db.server' +import { handleDestroyAllProjects } from '../routes/projects.destroy' +import { + createTestProject, + createTestSession, + createTestUser, + newTestRequest, + truncateTables, +} from '../test-util' +import { ApiError } from '../util/api.server' + +describe('handleDestroyAllProjects', () => { + afterEach(async () => { + await truncateTables([ + prisma.userDetails, + prisma.persistentSession, + prisma.project, + prisma.projectID, + ]) + }) + + beforeEach(async () => { + await createTestUser(prisma, { id: 'bob' }) + await createTestUser(prisma, { id: 'alice' }) + await createTestSession(prisma, { key: 'the-key', userId: 'bob' }) + await createTestProject(prisma, { id: 'one', ownerId: 'bob' }) + await createTestProject(prisma, { id: 'two', ownerId: 'bob', deleted: true }) + await createTestProject(prisma, { id: 'three', ownerId: 'alice', deleted: true }) + await createTestProject(prisma, { id: 'four', ownerId: 'bob' }) + await createTestProject(prisma, { id: 'five', ownerId: 'bob' }) + await createTestProject(prisma, { id: 'six', ownerId: 'bob', deleted: true }) + }) + + it('requires a user', async () => { + const fn = async () => + handleDestroyAllProjects(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {}) + await expect(fn).rejects.toThrow(ApiError) + await expect(fn).rejects.toThrow('session not found') + }) + it('hard-deletes all soft-deleted projects owned by the user', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleDestroyAllProjects(req, {}) + } + + await fn() + const got = await prisma.project.findMany({ where: { owner_id: 'bob' } }) + expect(got.map((p) => p.proj_id)).toEqual(['one', 'four', 'five']) + }) +}) diff --git a/utopia-remix/app/routes/projects.$id.destroy.tsx b/utopia-remix/app/routes/projects.$id.destroy.tsx new file mode 100644 index 000000000000..02266d0b7307 --- /dev/null +++ b/utopia-remix/app/routes/projects.$id.destroy.tsx @@ -0,0 +1,20 @@ +import { ActionFunctionArgs } from '@remix-run/node' +import { Params } from '@remix-run/react' +import { ensure, handle, requireUser } from '../util/api.server' +import { Status } from '../util/statusCodes.server' +import { hardDeleteProject } from '../models/project.server' + +export async function action(args: ActionFunctionArgs) { + return handle(args, { POST: handleDestroyProject }) +} + +export async function handleDestroyProject(req: Request, params: Params) { + const user = await requireUser(req) + + const { id } = params + ensure(id != null, 'id is null', Status.BAD_REQUEST) + + await hardDeleteProject({ id: id, userId: user.user_id }) + + return {} +} diff --git a/utopia-remix/app/routes/projects.destroy.tsx b/utopia-remix/app/routes/projects.destroy.tsx new file mode 100644 index 000000000000..28091d24be03 --- /dev/null +++ b/utopia-remix/app/routes/projects.destroy.tsx @@ -0,0 +1,16 @@ +import { ActionFunctionArgs } from '@remix-run/node' +import { Params } from '@remix-run/react' +import { hardDeleteAllProjects } from '../models/project.server' +import { handle, requireUser } from '../util/api.server' + +export async function action(args: ActionFunctionArgs) { + return handle(args, { POST: handleDestroyAllProjects }) +} + +export async function handleDestroyAllProjects(req: Request, params: Params) { + const user = await requireUser(req) + + await hardDeleteAllProjects({ userId: user.user_id }) + + return {} +} diff --git a/utopia-remix/app/routes/projects.tsx b/utopia-remix/app/routes/projects.tsx index 37eeee7a954c..bebf9f97231b 100644 --- a/utopia-remix/app/routes/projects.tsx +++ b/utopia-remix/app/routes/projects.tsx @@ -3,11 +3,12 @@ import { useLoaderData } from '@remix-run/react' import moment from 'moment' import { UserDetails } from 'prisma-client' import React, { useEffect, useState } from 'react' -import { ProjectWithoutContent, listProjects } from '../models/project.server' +import { listProjects } from '../models/project.server' import { newProjectButton } from '../styles/newProjectButton.css' import { projectCategoryButton, userName } from '../styles/sidebarComponents.css' import { sprinkles } from '../styles/sprinkles.css' import { requireUser } from '../util/api.server' +import { ProjectWithoutContent } from '../types' export async function loader(args: LoaderFunctionArgs) { const user = await requireUser(args.request)