feat(m12): meetings + schedule conflict detection, tests, e2e
- MeetingRepository (CRUD, soft-delete, members, findMeetingsByUserInRange overlap) + CalendarRepository.findByCreatorInRange + TodoRepository. findHighPriorityByAssignee - MeetingService (createMeeting tx+members, meeting_invited notify, meeting_created activity, SSE meeting.created; updateMeeting/updateMinutes/ deleteMeeting; checkScheduleConflicts = other meetings overlap + calendar events overlap + high-priority todos due +-3d, time-overlap logic, excludeMeetingId; conflicts are warnings) - APIs: meetings list/create/edit + /check conflict check - Screens: meetings page + MeetingForm(member checkboxes, conflict display) + ConflictWarning, ProjectNav link - Unit tests (MeetingRepository, MeetingService incl. conflict algorithm) + e2e meetings
This commit is contained in:
77
tests/e2e/meetings.spec.ts
Normal file
77
tests/e2e/meetings.spec.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
function unique(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
async function setupOwner(page: import('@playwright/test').Page) {
|
||||
const email = unique('owner') + '@example.com';
|
||||
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/);
|
||||
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||
}
|
||||
|
||||
test.describe('meetings & schedule conflict', () => {
|
||||
test('create meetings and detect a schedule conflict for a member', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const projectId = await setupOwner(page);
|
||||
const memberEmail = unique('member') + '@example.com';
|
||||
|
||||
// メンバー登録してプロジェクトに追加
|
||||
await request.post('/api/auth/register', {
|
||||
data: { name: 'Member', email: memberEmail, password: 'password123' },
|
||||
});
|
||||
const addRes = await page.request.post(
|
||||
`/api/projects/${projectId}/members`,
|
||||
{ data: { email: memberEmail, role: 'member' } }
|
||||
);
|
||||
const memberId = ((await addRes.json()) as { member: { userId: number } })
|
||||
.member.userId;
|
||||
|
||||
// 1件目ミーティング(10:00-11:00)に member 参加
|
||||
const m1 = await page.request.post(`/api/projects/${projectId}/meetings`, {
|
||||
data: {
|
||||
title: unique('M1'),
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
memberIds: [memberId],
|
||||
},
|
||||
});
|
||||
expect(m1.ok()).toBeTruthy();
|
||||
expect(((await m1.json()) as { conflicts: unknown[] }).conflicts).toEqual(
|
||||
[]
|
||||
);
|
||||
|
||||
// 2件目(10:30-11:30)は member にとって重複 → 警告が返る
|
||||
const m2 = await page.request.post(`/api/projects/${projectId}/meetings`, {
|
||||
data: {
|
||||
title: unique('M2'),
|
||||
startAt: '2026-06-15T10:30:00',
|
||||
endAt: '2026-06-15T11:30:00',
|
||||
memberIds: [memberId],
|
||||
},
|
||||
});
|
||||
expect(m2.ok()).toBeTruthy();
|
||||
const m2Body = (await m2.json()) as {
|
||||
meeting: { id: number };
|
||||
conflicts: { type: string }[];
|
||||
};
|
||||
expect(m2Body.conflicts.some((c) => c.type === 'meeting')).toBe(true);
|
||||
|
||||
// ミーティング一覧画面に両方表示
|
||||
await page.goto(`/projects/${projectId}/meetings`);
|
||||
await expect(
|
||||
page.getByTestId(`meeting-${m2Body.meeting.id}`)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
98
tests/unit/repositories/MeetingRepository.test.ts
Normal file
98
tests/unit/repositories/MeetingRepository.test.ts
Normal file
@ -0,0 +1,98 @@
|
||||
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 { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||
|
||||
describe('MeetingRepository', () => {
|
||||
let db: SqliteDatabase;
|
||||
let repo: MeetingRepository;
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
repo = new MeetingRepository(db);
|
||||
userId = new UserRepository(db).create({
|
||||
name: 'U',
|
||||
email: 'u@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
function createMeeting(title: string, startAt: string, endAt: string) {
|
||||
return repo.create({
|
||||
projectId,
|
||||
title,
|
||||
startAt,
|
||||
endAt,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
it('creates and finds a meeting', () => {
|
||||
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||
expect(repo.findById(m.id)?.title).toBe('M');
|
||||
expect(repo.findByProject(projectId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes soft-deleted meetings', () => {
|
||||
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||
repo.delete(m.id);
|
||||
expect(repo.findById(m.id)).toBeNull();
|
||||
expect(repo.findByProject(projectId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('updates a meeting', () => {
|
||||
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||
expect(repo.update(m.id, { title: 'M2', minutesMd: 'mins' })?.title).toBe(
|
||||
'M2'
|
||||
);
|
||||
expect(repo.findById(m.id)?.minutesMd).toBe('mins');
|
||||
});
|
||||
|
||||
it('adds and lists members', () => {
|
||||
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||
repo.addMember(m.id, userId);
|
||||
expect(repo.findMembersByMeeting(m.id)).toHaveLength(1);
|
||||
expect(repo.findMembersByMeeting(m.id)[0].status).toBe('invited');
|
||||
});
|
||||
|
||||
it('removes a member', () => {
|
||||
const m = createMeeting('M', '2026-06-15T10:00:00', '2026-06-15T11:00:00');
|
||||
repo.addMember(m.id, userId);
|
||||
repo.removeMember(m.id, userId);
|
||||
expect(repo.findMembersByMeeting(m.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('findMeetingsByUserInRange returns overlapping meetings and respects excludeMeetingId', () => {
|
||||
const m1 = createMeeting(
|
||||
'overlapping',
|
||||
'2026-06-15T10:00:00',
|
||||
'2026-06-15T11:00:00'
|
||||
);
|
||||
repo.addMember(m1.id, userId);
|
||||
const found = repo.findMeetingsByUserInRange(
|
||||
userId,
|
||||
'2026-06-15T10:30:00',
|
||||
'2026-06-15T11:30:00'
|
||||
);
|
||||
expect(found.map((m) => m.id)).toContain(m1.id);
|
||||
const excluded = repo.findMeetingsByUserInRange(
|
||||
userId,
|
||||
'2026-06-15T10:30:00',
|
||||
'2026-06-15T11:30:00',
|
||||
m1.id
|
||||
);
|
||||
expect(excluded.map((m) => m.id)).not.toContain(m1.id);
|
||||
});
|
||||
});
|
||||
234
tests/unit/services/MeetingService.test.ts
Normal file
234
tests/unit/services/MeetingService.test.ts
Normal file
@ -0,0 +1,234 @@
|
||||
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 { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { MeetingService } from '@/services/MeetingService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import { ForbiddenError } from '@/lib/errors';
|
||||
|
||||
function makeService(db: SqliteDatabase) {
|
||||
return new MeetingService(
|
||||
new MeetingRepository(db),
|
||||
new CalendarRepository(db),
|
||||
new TodoRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
new SseHub(),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
describe('MeetingService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let service: MeetingService;
|
||||
let projectId: number;
|
||||
let authorId: number;
|
||||
let memberId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
service = makeService(db);
|
||||
const users = new UserRepository(db);
|
||||
authorId = users.create({
|
||||
name: 'A',
|
||||
email: 'a@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
memberId = users.create({
|
||||
name: 'M',
|
||||
email: 'm@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: authorId,
|
||||
}).id;
|
||||
const members = new ProjectMemberRepository(db);
|
||||
members.add(projectId, authorId, 'admin');
|
||||
members.add(projectId, memberId, 'member');
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it('creates a meeting, invites members, notifies + logs activity, returns conflicts', () => {
|
||||
const { meeting, conflicts } = service.createMeeting(authorId, projectId, {
|
||||
title: 'Kickoff',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
memberIds: [memberId],
|
||||
});
|
||||
expect(meeting.title).toBe('Kickoff');
|
||||
expect(conflicts).toEqual([]);
|
||||
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1);
|
||||
expect(
|
||||
new ActivityLogRepository(db)
|
||||
.findByProject(projectId)
|
||||
.items.some((l) => l.action === 'meeting_created')
|
||||
).toBe(true);
|
||||
expect(
|
||||
new MeetingRepository(db).findMembersByMeeting(meeting.id)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('checkScheduleConflicts', () => {
|
||||
it('detects an overlapping meeting the member attends', () => {
|
||||
// 既存ミーティングに member が参加(10:00-11:00)
|
||||
const existing = service.createMeeting(authorId, projectId, {
|
||||
title: 'existing',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
memberIds: [memberId],
|
||||
}).meeting;
|
||||
// 新しいミーティング(10:30-11:30)は重複
|
||||
const conflicts = service.checkScheduleConflicts(
|
||||
projectId,
|
||||
[memberId],
|
||||
'2026-06-15T10:30:00',
|
||||
'2026-06-15T11:30:00'
|
||||
);
|
||||
expect(
|
||||
conflicts.some((c) => c.type === 'meeting' && c.refId === existing.id)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag a non-overlapping meeting', () => {
|
||||
service.createMeeting(authorId, projectId, {
|
||||
title: 'existing',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
memberIds: [memberId],
|
||||
});
|
||||
const conflicts = service.checkScheduleConflicts(
|
||||
projectId,
|
||||
[memberId],
|
||||
'2026-06-15T11:00:00',
|
||||
'2026-06-15T12:00:00'
|
||||
);
|
||||
expect(conflicts.filter((c) => c.type === 'meeting')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes the meeting itself via excludeMeetingId', () => {
|
||||
const existing = service.createMeeting(authorId, projectId, {
|
||||
title: 'existing',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
memberIds: [memberId],
|
||||
}).meeting;
|
||||
const conflicts = service.checkScheduleConflicts(
|
||||
projectId,
|
||||
[memberId],
|
||||
'2026-06-15T10:30:00',
|
||||
'2026-06-15T11:30:00',
|
||||
existing.id
|
||||
);
|
||||
expect(conflicts.filter((c) => c.type === 'meeting')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects an overlapping calendar event created by the member', () => {
|
||||
new CalendarRepository(db).create({
|
||||
projectId,
|
||||
title: 'member event',
|
||||
type: 'custom',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
createdById: memberId,
|
||||
});
|
||||
const conflicts = service.checkScheduleConflicts(
|
||||
projectId,
|
||||
[memberId],
|
||||
'2026-06-15T10:30:00',
|
||||
'2026-06-15T11:30:00'
|
||||
);
|
||||
expect(conflicts.some((c) => c.type === 'calendar_event')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a high-priority todo due within ±3 days', () => {
|
||||
const col = new TodoRepository(db).createColumn({
|
||||
projectId,
|
||||
name: 'Todo',
|
||||
orderIndex: 0,
|
||||
}).id;
|
||||
new TodoRepository(db).createItem({
|
||||
projectId,
|
||||
columnId: col,
|
||||
title: 'urgent',
|
||||
creatorId: authorId,
|
||||
assigneeId: memberId,
|
||||
priority: 'high',
|
||||
dueDate: '2026-06-16',
|
||||
orderIndex: 0,
|
||||
});
|
||||
const conflicts = service.checkScheduleConflicts(
|
||||
projectId,
|
||||
[memberId],
|
||||
'2026-06-15T10:00:00',
|
||||
'2026-06-15T11:00:00'
|
||||
);
|
||||
expect(conflicts.some((c) => c.type === 'important_todo')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a high-priority todo due outside ±3 days', () => {
|
||||
const col = new TodoRepository(db).createColumn({
|
||||
projectId,
|
||||
name: 'Todo',
|
||||
orderIndex: 0,
|
||||
}).id;
|
||||
new TodoRepository(db).createItem({
|
||||
projectId,
|
||||
columnId: col,
|
||||
title: 'far',
|
||||
creatorId: authorId,
|
||||
assigneeId: memberId,
|
||||
priority: 'high',
|
||||
dueDate: '2026-07-01',
|
||||
orderIndex: 0,
|
||||
});
|
||||
const conflicts = service.checkScheduleConflicts(
|
||||
projectId,
|
||||
[memberId],
|
||||
'2026-06-15T10:00:00',
|
||||
'2026-06-15T11:00:00'
|
||||
);
|
||||
expect(conflicts.filter((c) => c.type === 'important_todo')).toHaveLength(
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMinutes sets minutes', () => {
|
||||
const { meeting } = service.createMeeting(authorId, projectId, {
|
||||
title: 'M',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
memberIds: [],
|
||||
});
|
||||
expect(
|
||||
service.updateMinutes(authorId, meeting.id, '# 議事録').minutesMd
|
||||
).toBe('# 議事録');
|
||||
});
|
||||
|
||||
it('delete requires creator or admin', () => {
|
||||
const { meeting } = service.createMeeting(authorId, projectId, {
|
||||
title: 'M',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
memberIds: [],
|
||||
});
|
||||
// member(非作成者・非管理者)は削除不可
|
||||
expect(() => service.deleteMeeting(memberId, meeting.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
service.deleteMeeting(authorId, meeting.id); // 作成者
|
||||
expect(new MeetingRepository(db).findById(meeting.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user