- Add E2E notifications.spec (member-added notification -> list -> mark read) and activity-log.spec (board_posted + todo_created recorded) - Set Playwright workers=1 for deterministic full-suite runs (SSE test under parallel load); full E2E suite (18 tests / 13 specs incl. 12 mandated scenarios) green - Quality gate: 251 unit/integration tests, lint clean, typecheck clean, build succeeds, coverage 91.13% Repository/Service (>=80%) - Validation fixes (mandatory): SearchService now searches ALL items (large pageSize) instead of first page only; MeetingService. checkScheduleConflicts now requires membership (actorId) to prevent non-member info enumeration
241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
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(
|
|
authorId,
|
|
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(
|
|
authorId,
|
|
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(
|
|
authorId,
|
|
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(
|
|
authorId,
|
|
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(
|
|
authorId,
|
|
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(
|
|
authorId,
|
|
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();
|
|
});
|
|
});
|