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:
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