Files
OpenFrame/tests/api/projects.test.ts
yusufipk 39e81042bb feat(billing): let people try the product before handing over a card
The trial now starts inside the product, at email verification, and Stripe
grants none at all: checkout creates a subscription that bills immediately.
Verifying an address is what buys the seven days, which is also the cheapest
abuse control there is.

An unexpired trial is treated as an entitlement the account already holds, so a
Stripe sync can add access but never retracts a trial that has not run out. That
matters most for the abandoned checkout: the resulting incomplete subscription
carries no trial_end, and writing it through would have erased the days the
account still had and locked it out.

Unpaid accounts are bounded by what they can cost us rather than by what they
can do: one workspace, one project, 3 GiB of direct uploads. YouTube imports,
share links, guests, comments and approvals stay unlimited, because those are
the parts worth trying and they cost nothing. isPaidTier() is the new seam;
hasBillingAccess() answers a different question now that access no longer
implies a card.

Signup CTAs, the pricing card, the comparison pages, the terms and the refund
policy all said the trial converts to a paid plan by itself. It no longer does,
so they say what happens instead. Settings and a banner name both dates that
matter: when the trial ends, and the fifteen days after that during which
nothing is deleted.

/admin/growth compares the two funnels on signup to paid within a fixed 30 day
window, not trial to paid. Dropping the card requirement multiplies trials, so
the old ratio can fall while more people actually pay, and reading it that way
would retire the change for the wrong reason.
2026-08-05 19:40:36 +03:00

793 lines
27 KiB
TypeScript

import { beforeEach, describe, expect, it } from 'vitest';
import { db } from '@/lib/db';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { GET as listProjects, POST as createProjectRoute } from '@/app/api/projects/route';
import {
DELETE as deleteProject,
GET as getProject,
PATCH as patchProject,
} from '@/app/api/projects/[projectId]/route';
import { apiRequest, callRoute, readData, readJson } from '../helpers/request';
import { signedInAs, signedOut } from '../helpers/session';
import {
addProjectMember,
addWorkspaceMember,
createExpiredUser,
createProject,
createSubscribedUser,
createUser,
createVideo,
createWorkspace,
seedProject,
} from '../factories';
interface ListedProject {
id: string;
name: string;
}
async function listFor(userId: string, query = ''): Promise<Response> {
signedInAs({ id: userId });
return callRoute(listProjects, apiRequest(`/api/projects${query}`));
}
describe('GET /api/projects', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(listProjects, apiRequest('/api/projects'));
expect(response.status).toBe(401);
});
it.each([
['page=0', 'page 0 is below the minimum'],
['page=1001', 'page 1001 is past MAX_PAGE'],
['page=1.5', 'a non-integer page'],
['page=abc', 'an unparseable page'],
['limit=0', 'limit 0 is below the minimum'],
['limit=101', 'limit 101 is past MAX_LIMIT'],
['page=1000&limit=100', 'an offset of 99900 is past MAX_OFFSET'],
])('rejects ?%s with 400 (%s)', async (query, label) => {
const user = await createUser();
const response = await listFor(user.id, `?${query}`);
expect(response.status, label).toBe(400);
});
it('accepts the boundary values page=1000&limit=10 and limit=100', async () => {
const user = await createUser();
expect((await listFor(user.id, '?page=1000&limit=10')).status).toBe(200);
expect((await listFor(user.id, '?limit=100')).status).toBe(200);
});
it('lists projects the caller owns, with pagination metadata', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'Second' });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'Third' });
const response = await listFor(owner.id, '?page=2&limit=2');
const payload = await readJson<{
data: { projects: ListedProject[] };
meta: { page: number; limit: number; total: number; totalPages: number };
}>(response);
expect(response.status).toBe(200);
expect(payload.data.projects).toHaveLength(1);
expect(payload.meta).toEqual({ page: 2, limit: 2, total: 3, totalPages: 2 });
});
it('does not list projects belonging to another user', async () => {
const stranger = await seedProject();
const caller = await createUser();
const projects = await readData<{ projects: ListedProject[] }>(await listFor(caller.id));
expect(projects.projects).toEqual([]);
// Sanity check that the arrangement was real and the empty result is about
// the filter rather than about an empty database.
expect(await db.project.count()).toBe(1);
expect(stranger.project.id).toBeTruthy();
});
// This is the buildBillingAccessWhereInput() guard in the list filter. Both
// halves are here on purpose: without the positive control, deleting the
// clause entirely would still leave the negative test passing for the wrong
// reason.
it('hides a project whose workspace owner has lost billing access, even from its owner', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
const payload = await readJson<{
data: { projects: ListedProject[] };
meta: { total: number };
}>(await listFor(expiredOwner.id));
expect(payload.data.projects).toEqual([]);
expect(payload.meta.total).toBe(0);
});
it('lists the same project once the owner has billing access again', async () => {
const owner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: owner.id });
const project = await createProject({ ownerId: owner.id, workspaceId: workspace.id });
await db.user.update({
where: { id: owner.id },
data: { trialEndsAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
});
const projects = await readData<{ projects: ListedProject[] }>(await listFor(owner.id));
expect(projects.projects.map((entry) => entry.id)).toEqual([project.id]);
});
it('hides a project from a member when the workspace owner has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({
ownerId: expiredOwner.id,
workspaceId: workspace.id,
});
const member = await createUser();
await addProjectMember({ projectId: project.id, userId: member.id });
const projects = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
expect(projects.projects).toEqual([]);
});
it('lists a workspace member the projects of that workspace', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: member.id });
const projects = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
expect(projects.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
});
// The workspace-membership branch of the OR used to be dropped as soon as ?workspaceId
// was supplied, so filtering by their own workspace showed a member an empty list while
// the unfiltered call returned the same project.
it('still lists workspace-member projects when ?workspaceId is supplied', async () => {
const scenario = await seedProject();
const member = await createUser();
await addWorkspaceMember({ workspaceId: scenario.workspace.id, userId: member.id });
const unfiltered = await readData<{ projects: ListedProject[] }>(await listFor(member.id));
const filtered = await readData<{ projects: ListedProject[] }>(
await listFor(member.id, `?workspaceId=${scenario.workspace.id}`)
);
expect(unfiltered.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
expect(filtered.projects.map((entry) => entry.id)).toEqual([scenario.project.id]);
});
it('scopes ?workspaceId to that workspace for an owner of several', async () => {
const owner = await createUser();
const first = await createWorkspace({ ownerId: owner.id });
const second = await createWorkspace({ ownerId: owner.id });
const wanted = await createProject({ ownerId: owner.id, workspaceId: first.id });
await createProject({ ownerId: owner.id, workspaceId: second.id });
const projects = await readData<{ projects: ListedProject[] }>(
await listFor(owner.id, `?workspaceId=${first.id}`)
);
expect(projects.projects.map((entry) => entry.id)).toEqual([wanted.id]);
});
});
describe('POST /api/projects', () => {
it('returns 401 without a session', async () => {
signedOut();
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'X', workspaceId: 'y' } })
);
expect(response.status).toBe(401);
expect(await db.project.count()).toBe(0);
});
it.each([
[{ workspaceId: 'w' }, 'a missing name'],
[{ name: ' ', workspaceId: 'w' }, 'a blank name'],
[{ name: 42, workspaceId: 'w' }, 'a non-string name'],
[{ name: 'Valid' }, 'a missing workspaceId'],
[{ name: 'Valid', workspaceId: 17 }, 'a non-string workspaceId'],
])('rejects %j with 400 (%s)', async (body, label) => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(createProjectRoute, apiRequest('/api/projects', { body }));
expect(response.status, label).toBe(400);
expect(await db.project.count()).toBe(0);
});
it('returns 404 for a workspace that does not exist', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Orphan', workspaceId: 'no-such-workspace' } })
);
expect(response.status).toBe(404);
});
it('returns 403 for a workspace COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Sneaky', workspaceId: scenario.workspace.id },
})
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('returns 403 when the workspace owner has lost billing access', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
signedInAs(expiredOwner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Blocked', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(0);
});
it('creates the project with the five default comment tags', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'My Project', description: ' spaced ', workspaceId: workspace.id },
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow({ include: { commentTags: true } });
expect(stored.name).toBe('My Project');
expect(stored.description).toBe('spaced');
expect(stored.slug).toBe('my-project');
expect(stored.visibility).toBe('PRIVATE');
expect(stored.allowDownloads).toBe(false);
expect(stored.workspaceId).toBe(workspace.id);
expect(stored.commentTags.map((tag) => tag.name).sort()).toEqual(
DEFAULT_COMMENT_TAGS.map((tag) => tag.name).sort()
);
expect(stored.commentTags.map((tag) => tag.color).sort()).toEqual(
DEFAULT_COMMENT_TAGS.map((tag) => tag.color).sort()
);
});
// The row's owner is the workspace owner, never the caller: ownership drives
// billing, and a workspace admin creating a project must not shift the bill.
it('assigns the workspace owner as project owner when a workspace ADMIN creates it', async () => {
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const admin = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Admin Project', workspaceId: workspace.id },
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow();
expect(stored.ownerId).toBe(workspaceOwner.id);
expect(stored.ownerId).not.toBe(admin.id);
});
it('ignores an ownerId, slug and id supplied by the caller', async () => {
const owner = await createUser();
const impostor = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: {
name: 'Clean Slate',
workspaceId: workspace.id,
id: 'attacker-chosen-id',
ownerId: impostor.id,
slug: 'attacker-chosen-slug',
allowDownloads: true,
},
})
);
expect(response.status).toBe(201);
const stored = await db.project.findFirstOrThrow();
expect(stored.id).not.toBe('attacker-chosen-id');
expect(stored.ownerId).toBe(owner.id);
expect(stored.slug).toBe('clean-slate');
expect(stored.allowDownloads).toBe(false);
});
// Subscribed rather than the default trial user: three projects is past the
// trial's ceiling, and this test is about slugs, not about billing.
it('gives two projects with the same name distinct slugs', async () => {
const owner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
for (let index = 0; index < 3; index += 1) {
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Same Name', workspaceId: workspace.id } })
);
expect(response.status).toBe(201);
}
const slugs = (await db.project.findMany({ select: { slug: true } })).map((row) => row.slug);
expect(slugs.sort()).toEqual(['same-name', 'same-name-1', 'same-name-2']);
});
it('refuses a second project while the owner is on a free trial', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('lets a paying owner past that ceiling', async () => {
const owner = await createSubscribedUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(201);
expect(await db.project.count()).toBe(2);
});
// The ceiling belongs to the account being billed, so a workspace admin cannot
// spend somebody else's trial allowance either.
it('counts the ceiling against the workspace owner, not the caller', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
await createProject({ ownerId: owner.id, workspaceId: workspace.id, name: 'First' });
const admin = await createUser();
await addWorkspaceMember({ workspaceId: workspace.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', { body: { name: 'Second', workspaceId: workspace.id } })
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('honours an explicit PUBLIC visibility', async () => {
const owner = await createUser();
const workspace = await createWorkspace({ ownerId: owner.id });
signedInAs(owner);
const response = await callRoute(
createProjectRoute,
apiRequest('/api/projects', {
body: { name: 'Open', workspaceId: workspace.id, visibility: 'PUBLIC' },
})
);
expect(response.status).toBe(201);
expect((await db.project.findFirstOrThrow()).visibility).toBe('PUBLIC');
});
});
describe('GET /api/projects/[projectId]', () => {
it('returns 404 for an unknown project', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(getProject, apiRequest('/api/projects/nope'), {
projectId: 'nope',
});
expect(response.status).toBe(404);
});
it('returns 403 for a signed-in non-member on a PRIVATE project', async () => {
const scenario = await seedProject({ visibility: 'PRIVATE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 403 for a signed-in non-member on an INVITE project', async () => {
const scenario = await seedProject({ visibility: 'INVITE' });
const stranger = await createUser();
signedInAs(stranger);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
});
it('returns 200 to an anonymous caller on a PUBLIC project', async () => {
const scenario = await seedProject({ visibility: 'PUBLIC' });
signedOut();
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
});
it('returns 403 to the owner once their own billing access has lapsed', async () => {
const expiredOwner = await createExpiredUser();
const workspace = await createWorkspace({ ownerId: expiredOwner.id });
const project = await createProject({ ownerId: expiredOwner.id, workspaceId: workspace.id });
signedInAs(expiredOwner);
const response = await callRoute(getProject, apiRequest(`/api/projects/${project.id}`), {
projectId: project.id,
});
expect(response.status).toBe(403);
});
it.each([['limit=0'], ['limit=101'], ['offset=-1'], ['offset=10001'], ['offset=abc']])(
'rejects ?%s with 400',
async (query) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}?${query}`),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(400);
}
);
it('paginates the embedded video list with limit and offset', async () => {
const scenario = await seedProject();
for (let position = 0; position < 3; position += 1) {
await createVideo({ projectId: scenario.project.id, position });
}
signedInAs(scenario.owner);
const response = await callRoute(
getProject,
apiRequest(`/api/projects/${scenario.project.id}?limit=2&offset=2`),
{ projectId: scenario.project.id }
);
const project = await readData<{ videos: Array<{ id: string }>; _count: { videos: number } }>(
response
);
expect(response.status).toBe(200);
expect(project.videos).toHaveLength(1);
expect(project._count.videos).toBe(3);
});
});
describe('PATCH /api/projects/[projectId]', () => {
it('returns 401 without a session', async () => {
const scenario = await seedProject();
signedOut();
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: 'Renamed' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect((await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).name).toBe(
scenario.project.name
);
});
it('returns 403 for a project COMMENTATOR', async () => {
const scenario = await seedProject();
const commentator = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: commentator.id,
role: 'COMMENTATOR',
});
signedInAs(commentator);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: 'Renamed by a commentator' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect((await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).name).toBe(
scenario.project.name
);
});
it('returns 403 for an unknown project rather than 404', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
patchProject,
apiRequest('/api/projects/nope', { method: 'PATCH', body: { name: 'X' } }),
{ projectId: 'nope' }
);
expect(response.status).toBe(403);
});
it('lets a project ADMIN rename the project', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({
projectId: scenario.project.id,
userId: admin.id,
role: 'ADMIN',
});
signedInAs(admin);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { name: ' Renamed ', description: ' new description ', allowDownloads: true },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.name).toBe('Renamed');
expect(stored.description).toBe('new description');
expect(stored.allowDownloads).toBe(true);
});
it('lets a workspace ADMIN edit a project they are not a member of', async () => {
const scenario = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: { visibility: 'PUBLIC' },
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
expect(
(await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } })).visibility
).toBe('PUBLIC');
});
it.each([
[{ name: '' }, 'an empty name'],
[{ name: 'x'.repeat(101) }, 'a name over 100 characters'],
[{ description: 'x'.repeat(1001) }, 'a description over 1000 characters'],
[{ description: 5 }, 'a non-string description'],
[{ visibility: 'SEMI_PRIVATE' }, 'an unknown visibility'],
[{ allowDownloads: 'yes' }, 'a non-boolean allowDownloads'],
])('rejects %j with 400 (%s)', async (body, label) => {
const scenario = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'PATCH', body }),
{ projectId: scenario.project.id }
);
expect(response.status, label).toBe(400);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.name).toBe(scenario.project.name);
expect(stored.visibility).toBe(scenario.project.visibility);
});
it('ignores an ownerId and a workspaceId in the body', async () => {
const scenario = await seedProject();
const other = await seedProject();
signedInAs(scenario.owner);
const response = await callRoute(
patchProject,
apiRequest(`/api/projects/${scenario.project.id}`, {
method: 'PATCH',
body: {
name: 'Still Mine',
ownerId: other.owner.id,
workspaceId: other.workspace.id,
},
}),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
const stored = await db.project.findUniqueOrThrow({ where: { id: scenario.project.id } });
expect(stored.ownerId).toBe(scenario.owner.id);
expect(stored.workspaceId).toBe(scenario.workspace.id);
});
});
describe('DELETE /api/projects/[projectId]', () => {
beforeEach(() => {
signedOut();
});
it('returns 401 without a session', async () => {
const scenario = await seedProject();
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(401);
expect(await db.project.count()).toBe(1);
});
it('returns 404 for an unknown project', async () => {
const user = await createUser();
signedInAs(user);
const response = await callRoute(
deleteProject,
apiRequest('/api/projects/nope', { method: 'DELETE' }),
{ projectId: 'nope' }
);
expect(response.status).toBe(404);
});
// canDelete is deliberately narrower than canEdit: a project ADMIN may rename
// and configure the project but may not destroy it.
it('returns 403 for a project ADMIN', async () => {
const scenario = await seedProject();
const admin = await createUser();
await addProjectMember({ projectId: scenario.project.id, userId: admin.id, role: 'ADMIN' });
signedInAs(admin);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('returns 403 for a workspace ADMIN who is not the workspace owner', async () => {
const scenario = await seedProject();
const workspaceAdmin = await createUser();
await addWorkspaceMember({
workspaceId: scenario.workspace.id,
userId: workspaceAdmin.id,
role: 'ADMIN',
});
signedInAs(workspaceAdmin);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(403);
expect(await db.project.count()).toBe(1);
});
it('deletes the project and cascades to its videos for the owner', async () => {
const scenario = await seedProject();
await createVideo({ projectId: scenario.project.id });
signedInAs(scenario.owner);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${scenario.project.id}`, { method: 'DELETE' }),
{ projectId: scenario.project.id }
);
expect(response.status).toBe(200);
expect(await db.project.count()).toBe(0);
expect(await db.video.count()).toBe(0);
});
it('lets the workspace owner delete a project owned by someone else', async () => {
const workspaceOwner = await createUser();
const workspace = await createWorkspace({ ownerId: workspaceOwner.id });
const projectOwner = await createUser();
const project = await createProject({
ownerId: projectOwner.id,
workspaceId: workspace.id,
});
signedInAs(workspaceOwner);
const response = await callRoute(
deleteProject,
apiRequest(`/api/projects/${project.id}`, { method: 'DELETE' }),
{ projectId: project.id }
);
expect(response.status).toBe(200);
expect(await db.project.count()).toBe(0);
});
});