mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
test: add unit, API, component and end-to-end test suites
The repo had no automated tests. Every change was verified by hand. Adds four layers, 2023 tests in total, runnable with one command: - 1191 unit tests over the pure logic in lib/, including the full computeProjectAccess permission matrix and the billing gate - 167 component and hook tests in jsdom, covering the hooks that hold real logic rather than presentational wrappers - 647 API integration tests against a real Postgres, with only auth() mocked, including a data-driven sweep asserting that none of the 60 route modules answers 2xx to an unauthenticated caller - 18 Playwright specs driving a real browser against a real build Infrastructure: vitest.config.ts with three projects, a disposable Postgres and MinIO in docker-compose.test.yml, factories and helpers under tests/, scripts/test.sh as the single entry point, a pre-push hook running bun run verify, and CI split into check, test and e2e jobs. The test database is built with prisma db push plus a replay of the hand-written SQL, because prisma migrate deploy cannot build this schema from empty: the migration history has no captured baseline. This mirrors what scripts/docker-db-bootstrap.ts already does in production, and tests/setup/db-global.ts carries a drift guard so a new migration fails the run until someone reviews it. Production code is unchanged apart from one pure-function extraction out of use-video-player.ts, which was too large to test in jsdom. Several tests pin behaviour that looks wrong, each marked KNOWN BUG in place. TESTING.md section 12 records where the plan turned out to be wrong, and AGENTS.md now states which layer a change needs a test in.
This commit is contained in:
@@ -0,0 +1,741 @@
|
||||
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,
|
||||
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]);
|
||||
});
|
||||
|
||||
// Documents current behaviour, which looks like a bug. See the note in the
|
||||
// report: the workspace-membership branch of the OR is dropped as soon as
|
||||
// ?workspaceId is supplied, so filtering by workspace hides exactly the
|
||||
// projects the unfiltered call returns.
|
||||
it('stops listing workspace-member projects once ?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).toEqual([]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('gives two projects with the same name distinct slugs', async () => {
|
||||
const owner = await createUser();
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user