feat(m4): project & member management with tests and e2e
- ProjectRepository, ProjectMemberRepository, minimal NotificationRepository - ProjectService: create/update/archive/delete project, add/remove member, permission checks (isMember/admin), member-added notification, getDashboard skeleton, getMyProjects, getMemberRole - projectValidator, API services factory - API routes: projects CRUD, members list/add/remove - Screens: dashboard (project list + create form), project overview, members, settings; layout (Header/Sidebar/ProjectNav) and project components - Unit tests (Project/ProjectMember repositories, ProjectService), integration project-member-permission, e2e project-management
This commit is contained in:
125
tests/e2e/project-management.spec.ts
Normal file
125
tests/e2e/project-management.spec.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
function unique(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
async function registerAndLogin(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string
|
||||
) {
|
||||
await page.goto('/login');
|
||||
await page.getByRole('button', { name: '新規登録はこちら' }).click();
|
||||
await page.getByLabel('表示名').fill('Owner');
|
||||
await page.getByLabel('メールアドレス').fill(email);
|
||||
await page.getByLabel('パスワード').fill('password123');
|
||||
await page.getByRole('button', { name: '登録する' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
}
|
||||
|
||||
function extractProjectId(url: string): number {
|
||||
const match = url.match(/\/projects\/(\d+)/);
|
||||
if (!match) throw new Error(`project id not found in ${url}`);
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
test.describe('project management', () => {
|
||||
test('create a project, add and remove a member, then archive', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const ownerEmail = unique('owner') + '@example.com';
|
||||
const memberEmail = unique('member') + '@example.com';
|
||||
const projectName = unique('Project');
|
||||
|
||||
// オーナー登録+ログイン
|
||||
await registerAndLogin(page, ownerEmail);
|
||||
|
||||
// プロジェクト作成(UI)
|
||||
await page.getByLabel('プロジェクト名').fill(projectName);
|
||||
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: projectName })
|
||||
).toBeVisible();
|
||||
const projectId = extractProjectId(page.url());
|
||||
|
||||
// 追加用メンバーを事前登録
|
||||
await request.post('/api/auth/register', {
|
||||
data: {
|
||||
name: 'Member User',
|
||||
email: memberEmail,
|
||||
password: 'password123',
|
||||
},
|
||||
});
|
||||
|
||||
// メンバー追加(オーナーのセッションでAPI呼び出し)
|
||||
const addRes = await page.request.post(
|
||||
`/api/projects/${projectId}/members`,
|
||||
{ data: { email: memberEmail, role: 'member' } }
|
||||
);
|
||||
expect(addRes.ok()).toBeTruthy();
|
||||
const { member } = (await addRes.json()) as {
|
||||
member: { userId: number };
|
||||
};
|
||||
|
||||
// メンバー一覧画面に反映されているか検証(UI)
|
||||
await page.goto(`/projects/${projectId}/members`);
|
||||
await expect(page.getByText('Member User')).toBeVisible();
|
||||
|
||||
// メンバー削除(API) → 一覧から消えるか検証(UI)
|
||||
const delRes = await page.request.delete(
|
||||
`/api/projects/${projectId}/members/${member.userId}`
|
||||
);
|
||||
expect(delRes.ok()).toBeTruthy();
|
||||
await page.reload();
|
||||
await expect(page.getByText('Member User')).toHaveCount(0);
|
||||
|
||||
// アーカイブ(API) → 概要画面に反映されているか検証(UI)
|
||||
const archiveRes = await page.request.patch(`/api/projects/${projectId}`, {
|
||||
data: { status: 'archived' },
|
||||
});
|
||||
expect(archiveRes.ok()).toBeTruthy();
|
||||
await page.goto(`/projects/${projectId}`);
|
||||
await expect(page.getByText('Archived')).toBeVisible();
|
||||
});
|
||||
|
||||
test('non-member cannot access a project they do not belong to', async ({
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
const ownerEmail = unique('owner') + '@example.com';
|
||||
const outsiderEmail = unique('outsider') + '@example.com';
|
||||
|
||||
// オーナーがプロジェクトを作成
|
||||
const ownerContext = await browser.newContext();
|
||||
const ownerPage = await ownerContext.newPage();
|
||||
await registerAndLogin(ownerPage, ownerEmail);
|
||||
await ownerPage.getByLabel('プロジェクト名').fill(unique('Secret'));
|
||||
await ownerPage.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||
const projectUrl = ownerPage.url();
|
||||
|
||||
// 外部ユーザーは登録のみ(プロジェクト非参加)
|
||||
await request.post('/api/auth/register', {
|
||||
data: {
|
||||
name: 'Outsider',
|
||||
email: outsiderEmail,
|
||||
password: 'password123',
|
||||
},
|
||||
});
|
||||
const outsiderContext = await browser.newContext();
|
||||
const outsiderPage = await outsiderContext.newPage();
|
||||
await outsiderPage.goto('/login');
|
||||
await outsiderPage.getByLabel('メールアドレス').fill(outsiderEmail);
|
||||
await outsiderPage.getByLabel('パスワード').fill('password123');
|
||||
await outsiderPage.getByRole('button', { name: 'ログイン' }).click();
|
||||
await expect(outsiderPage).toHaveURL(/\/dashboard/);
|
||||
|
||||
// プロジェクト詳細へ直接アクセス → ダッシュボードへリダイレクト
|
||||
await outsiderPage.goto(projectUrl);
|
||||
await expect(outsiderPage).toHaveURL(/\/dashboard$/);
|
||||
|
||||
await ownerContext.close();
|
||||
await outsiderContext.close();
|
||||
});
|
||||
});
|
||||
99
tests/integration/project-member-permission.test.ts
Normal file
99
tests/integration/project-member-permission.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ProjectService } from '@/services/ProjectService';
|
||||
import { ForbiddenError } from '@/lib/errors';
|
||||
|
||||
describe('project member permission (integration)', () => {
|
||||
let db: SqliteDatabase;
|
||||
let userRepo: UserRepository;
|
||||
let service: ProjectService;
|
||||
let ownerA: number;
|
||||
let memberB: number;
|
||||
let outsiderC: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
userRepo = new UserRepository(db);
|
||||
service = new ProjectService(
|
||||
new ProjectRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationRepository(db),
|
||||
db
|
||||
);
|
||||
|
||||
ownerA = userRepo.create({
|
||||
name: 'A',
|
||||
email: 'a@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
memberB = userRepo.create({
|
||||
name: 'B',
|
||||
email: 'b@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
outsiderC = userRepo.create({
|
||||
name: 'C',
|
||||
email: 'c@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('non-members cannot access any project data; members can', () => {
|
||||
const p1 = service.createProject(ownerA, { name: 'P1' });
|
||||
service.addMember(ownerA, p1.id, memberB, 'member');
|
||||
|
||||
// メンバー B はアクセス可能
|
||||
expect(() => service.getProject(memberB, p1.id)).not.toThrow();
|
||||
expect(() => service.getDashboard(memberB, p1.id)).not.toThrow();
|
||||
expect(service.getMembers(memberB, p1.id).length).toBeGreaterThan(0);
|
||||
|
||||
// 非メンバー C はすべて拒否
|
||||
expect(() => service.getProject(outsiderC, p1.id)).toThrow(ForbiddenError);
|
||||
expect(() => service.getDashboard(outsiderC, p1.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
expect(() => service.getMembers(outsiderC, p1.id)).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('a member of one project cannot access another project', () => {
|
||||
const p1 = service.createProject(ownerA, { name: 'P1' });
|
||||
const p2 = service.createProject(ownerA, { name: 'P2' });
|
||||
service.addMember(ownerA, p1.id, memberB, 'member');
|
||||
|
||||
// B は P1 のメンバーだが P2 には非参加
|
||||
expect(() => service.getProject(memberB, p2.id)).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('a non-admin member cannot manage members or edit the project', () => {
|
||||
const p1 = service.createProject(ownerA, { name: 'P1' });
|
||||
service.addMember(ownerA, p1.id, memberB, 'member');
|
||||
|
||||
expect(() =>
|
||||
service.updateProject(memberB, p1.id, { name: 'hacked' })
|
||||
).toThrow(ForbiddenError);
|
||||
expect(() =>
|
||||
service.addMember(memberB, p1.id, outsiderC, 'member')
|
||||
).toThrow(ForbiddenError);
|
||||
expect(() => service.archiveProject(memberB, p1.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
});
|
||||
|
||||
it('getMyProjects only returns projects the user belongs to', () => {
|
||||
const p1 = service.createProject(ownerA, { name: 'P1' });
|
||||
service.createProject(ownerA, { name: 'P2' });
|
||||
service.addMember(ownerA, p1.id, memberB, 'member');
|
||||
|
||||
const projectsForB = service.getMyProjects(memberB);
|
||||
expect(projectsForB.map((p) => p.id)).toEqual([p1.id]);
|
||||
});
|
||||
});
|
||||
100
tests/unit/repositories/ProjectMemberRepository.test.ts
Normal file
100
tests/unit/repositories/ProjectMemberRepository.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
|
||||
describe('ProjectMemberRepository', () => {
|
||||
let db: SqliteDatabase;
|
||||
let userRepo: UserRepository;
|
||||
let projectRepo: ProjectRepository;
|
||||
let repo: ProjectMemberRepository;
|
||||
let ownerId: number;
|
||||
let projectId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
userRepo = new UserRepository(db);
|
||||
projectRepo = new ProjectRepository(db);
|
||||
repo = new ProjectMemberRepository(db);
|
||||
ownerId = userRepo.create({
|
||||
name: 'Owner',
|
||||
email: 'owner@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = projectRepo.create({ name: 'P', ownerId }).id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('add / findByProject', () => {
|
||||
it('adds a member and lists them with user info', () => {
|
||||
repo.add(projectId, ownerId, 'admin');
|
||||
|
||||
const members = repo.findByProject(projectId);
|
||||
expect(members).toHaveLength(1);
|
||||
expect(members[0].userId).toBe(ownerId);
|
||||
expect(members[0].role).toBe('admin');
|
||||
expect(members[0].user.email).toBe('owner@example.com');
|
||||
});
|
||||
|
||||
it('rejects adding the same member twice (UNIQUE constraint)', () => {
|
||||
repo.add(projectId, ownerId, 'admin');
|
||||
expect(() => repo.add(projectId, ownerId, 'member')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMember', () => {
|
||||
it('returns true for a member and false otherwise', () => {
|
||||
repo.add(projectId, ownerId, 'admin');
|
||||
|
||||
expect(repo.isMember(projectId, ownerId)).toBe(true);
|
||||
expect(repo.isMember(projectId, 99999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRole', () => {
|
||||
it('returns the role for a member', () => {
|
||||
repo.add(projectId, ownerId, 'admin');
|
||||
|
||||
expect(repo.getRole(projectId, ownerId)).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns null for a non-member', () => {
|
||||
expect(repo.getRole(projectId, 99999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes a member and returns true', () => {
|
||||
repo.add(projectId, ownerId, 'admin');
|
||||
|
||||
expect(repo.remove(projectId, ownerId)).toBe(true);
|
||||
expect(repo.isMember(projectId, ownerId)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the member does not exist', () => {
|
||||
expect(repo.remove(projectId, 99999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUser', () => {
|
||||
it('returns memberships for a user across projects', () => {
|
||||
const other = userRepo.create({
|
||||
name: 'Other',
|
||||
email: 'other@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
const p2 = projectRepo.create({ name: 'P2', ownerId }).id;
|
||||
|
||||
repo.add(projectId, other, 'member');
|
||||
repo.add(p2, other, 'admin');
|
||||
|
||||
const memberships = repo.findByUser(other);
|
||||
expect(memberships).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
tests/unit/repositories/ProjectRepository.test.ts
Normal file
119
tests/unit/repositories/ProjectRepository.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
|
||||
describe('ProjectRepository', () => {
|
||||
let db: SqliteDatabase;
|
||||
let userRepo: UserRepository;
|
||||
let repo: ProjectRepository;
|
||||
let memberRepo: ProjectMemberRepository;
|
||||
let ownerId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
userRepo = new UserRepository(db);
|
||||
repo = new ProjectRepository(db);
|
||||
memberRepo = new ProjectMemberRepository(db);
|
||||
ownerId = userRepo.create({
|
||||
name: 'Owner',
|
||||
email: 'owner@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a project with active status and returns it', () => {
|
||||
const project = repo.create({
|
||||
name: 'Project A',
|
||||
description: 'desc',
|
||||
ownerId,
|
||||
});
|
||||
|
||||
expect(project.id).toBeGreaterThan(0);
|
||||
expect(project.name).toBe('Project A');
|
||||
expect(project.description).toBe('desc');
|
||||
expect(project.status).toBe('active');
|
||||
expect(project.ownerId).toBe(ownerId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns the project when found', () => {
|
||||
const created = repo.create({ name: 'P', ownerId });
|
||||
|
||||
expect(repo.findById(created.id)?.name).toBe('P');
|
||||
});
|
||||
|
||||
it('returns null when not found', () => {
|
||||
expect(repo.findById(99999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByOwner', () => {
|
||||
it('returns projects owned by the user', () => {
|
||||
repo.create({ name: 'P1', ownerId });
|
||||
repo.create({ name: 'P2', ownerId });
|
||||
|
||||
const owned = repo.findByOwner(ownerId);
|
||||
expect(owned).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findProjectsByUserId', () => {
|
||||
it('returns only projects the user is a member of', () => {
|
||||
const p1 = repo.create({ name: 'P1', ownerId });
|
||||
const p2 = repo.create({ name: 'P2', ownerId });
|
||||
const other = userRepo.create({
|
||||
name: 'Other',
|
||||
email: 'other@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
|
||||
memberRepo.add(p1.id, other, 'member');
|
||||
|
||||
const projects = repo.findProjectsByUserId(other);
|
||||
expect(projects.map((p) => p.id)).toEqual([p1.id]);
|
||||
expect(projects.map((p) => p.id)).not.toContain(p2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updates name, description and status', () => {
|
||||
const created = repo.create({ name: 'P', ownerId });
|
||||
|
||||
const updated = repo.update(created.id, {
|
||||
name: 'P2',
|
||||
description: 'new desc',
|
||||
status: 'archived',
|
||||
});
|
||||
|
||||
expect(updated?.name).toBe('P2');
|
||||
expect(updated?.description).toBe('new desc');
|
||||
expect(updated?.status).toBe('archived');
|
||||
});
|
||||
|
||||
it('returns null for a non-existent project', () => {
|
||||
expect(repo.update(99999, { name: 'X' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes the project and returns true', () => {
|
||||
const created = repo.create({ name: 'P', ownerId });
|
||||
|
||||
expect(repo.delete(created.id)).toBe(true);
|
||||
expect(repo.findById(created.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false when the project does not exist', () => {
|
||||
expect(repo.delete(99999)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
230
tests/unit/services/ProjectService.test.ts
Normal file
230
tests/unit/services/ProjectService.test.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ProjectService } from '@/services/ProjectService';
|
||||
import { ForbiddenError, NotFoundError, ConflictError } from '@/lib/errors';
|
||||
|
||||
function createService(db: SqliteDatabase) {
|
||||
return new ProjectService(
|
||||
new ProjectRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationRepository(db),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
function createUser(repo: UserRepository, email: string, name = 'User') {
|
||||
return repo.create({ name, email, passwordHash: 'hash' });
|
||||
}
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let userRepo: UserRepository;
|
||||
let service: ProjectService;
|
||||
let ownerId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
userRepo = new UserRepository(db);
|
||||
service = createService(db);
|
||||
ownerId = createUser(userRepo, 'owner@example.com', 'Owner').id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
describe('createProject', () => {
|
||||
it('creates the project and registers the owner as an admin member', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
|
||||
expect(project.name).toBe('P1');
|
||||
expect(project.ownerId).toBe(ownerId);
|
||||
|
||||
const dashboard = service.getDashboard(ownerId, project.id);
|
||||
expect(dashboard.members).toHaveLength(1);
|
||||
expect(dashboard.members[0].role).toBe('admin');
|
||||
expect(service.getMemberRole(ownerId, project.id)).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMyProjects', () => {
|
||||
it('lists projects the user participates in', () => {
|
||||
service.createProject(ownerId, { name: 'P1' });
|
||||
service.createProject(ownerId, { name: 'P2' });
|
||||
|
||||
const projects = service.getMyProjects(ownerId);
|
||||
expect(projects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateProject', () => {
|
||||
it('allows an admin to update the project', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
|
||||
const updated = service.updateProject(ownerId, project.id, {
|
||||
name: 'P1-updated',
|
||||
status: 'on_hold',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('P1-updated');
|
||||
expect(updated.status).toBe('on_hold');
|
||||
});
|
||||
|
||||
it('forbids a non-admin member from updating', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const member = createUser(userRepo, 'member@example.com').id;
|
||||
service.addMember(ownerId, project.id, member, 'member');
|
||||
|
||||
expect(() =>
|
||||
service.updateProject(member, project.id, { name: 'X' })
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('forbids a non-member from updating', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const outsider = createUser(userRepo, 'out@example.com').id;
|
||||
|
||||
expect(() =>
|
||||
service.updateProject(outsider, project.id, { name: 'X' })
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for a non-existent project', () => {
|
||||
expect(() =>
|
||||
service.updateProject(ownerId, 99999, { name: 'X' })
|
||||
).toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveProject', () => {
|
||||
it('sets the project status to archived', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
|
||||
const archived = service.archiveProject(ownerId, project.id);
|
||||
|
||||
expect(archived.status).toBe('archived');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteProject', () => {
|
||||
it('deletes the project when the actor is an admin', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
|
||||
service.deleteProject(ownerId, project.id);
|
||||
|
||||
expect(() => service.getProject(ownerId, project.id)).toThrow(
|
||||
NotFoundError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMember', () => {
|
||||
it('adds a member and creates a project_added notification', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const newMember = createUser(userRepo, 'new@example.com').id;
|
||||
|
||||
service.addMember(ownerId, project.id, newMember, 'member');
|
||||
|
||||
expect(service.getMemberRole(newMember, project.id)).toBe('member');
|
||||
const notifications = db.query<{
|
||||
id: number;
|
||||
type: string;
|
||||
user_id: number;
|
||||
}>(
|
||||
'SELECT id, type, user_id FROM notifications WHERE user_id = @userId',
|
||||
{ userId: newMember }
|
||||
);
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].type).toBe('project_added');
|
||||
});
|
||||
|
||||
it('rejects adding an existing member with ConflictError', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const newMember = createUser(userRepo, 'new@example.com').id;
|
||||
service.addMember(ownerId, project.id, newMember, 'member');
|
||||
|
||||
expect(() =>
|
||||
service.addMember(ownerId, project.id, newMember, 'member')
|
||||
).toThrow(ConflictError);
|
||||
});
|
||||
|
||||
it('forbids a non-admin from adding members', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const member = createUser(userRepo, 'member@example.com').id;
|
||||
service.addMember(ownerId, project.id, member, 'member');
|
||||
const target = createUser(userRepo, 'target@example.com').id;
|
||||
|
||||
expect(() =>
|
||||
service.addMember(member, project.id, target, 'member')
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('allows an admin to remove another member', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const member = createUser(userRepo, 'member@example.com').id;
|
||||
service.addMember(ownerId, project.id, member, 'member');
|
||||
|
||||
service.removeMember(ownerId, project.id, member);
|
||||
|
||||
expect(service.getMemberRole(member, project.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('allows a member to remove themselves', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const member = createUser(userRepo, 'member@example.com').id;
|
||||
service.addMember(ownerId, project.id, member, 'member');
|
||||
|
||||
service.removeMember(member, project.id, member);
|
||||
|
||||
expect(service.getMemberRole(member, project.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('forbids a member from removing another member', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const member1 = createUser(userRepo, 'm1@example.com').id;
|
||||
const member2 = createUser(userRepo, 'm2@example.com').id;
|
||||
service.addMember(ownerId, project.id, member1, 'member');
|
||||
service.addMember(ownerId, project.id, member2, 'member');
|
||||
|
||||
expect(() => service.removeMember(member1, project.id, member2)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDashboard / getProject', () => {
|
||||
it('returns the project and members for a member', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
|
||||
const dashboard = service.getDashboard(ownerId, project.id);
|
||||
expect(dashboard.project.id).toBe(project.id);
|
||||
expect(dashboard.members).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('forbids a non-member from accessing the project (isolation)', () => {
|
||||
const project = service.createProject(ownerId, { name: 'P1' });
|
||||
const outsider = createUser(userRepo, 'out@example.com').id;
|
||||
|
||||
expect(() => service.getProject(outsider, project.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
expect(() => service.getDashboard(outsider, project.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
expect(() => service.getMembers(outsider, project.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for a non-existent project', () => {
|
||||
expect(() => service.getProject(ownerId, 99999)).toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user