diff --git a/app/api/projects/[projectId]/meetings/[id]/route.ts b/app/api/projects/[projectId]/meetings/[id]/route.ts index ce7859c..46eb3ca 100644 --- a/app/api/projects/[projectId]/meetings/[id]/route.ts +++ b/app/api/projects/[projectId]/meetings/[id]/route.ts @@ -61,6 +61,7 @@ export async function POST( const service = createMeetingService(); try { const conflicts = service.checkScheduleConflicts( + user.id, Number(projectId), Array.isArray(body.memberIds) ? body.memberIds.map(Number) : [], String(body.startAt ?? ''), diff --git a/playwright.config.ts b/playwright.config.ts index 4addb17..f8d5a2b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: 1, reporter: 'html', globalSetup: './tests/e2e/globalSetup.ts', use: { diff --git a/services/MeetingService.ts b/services/MeetingService.ts index 273ae87..070574d 100644 --- a/services/MeetingService.ts +++ b/services/MeetingService.ts @@ -75,6 +75,7 @@ export class MeetingService { this.requireMember(projectId, actorId); this.validate(input); const conflicts = this.checkScheduleConflicts( + actorId, projectId, input.memberIds, input.startAt, @@ -175,12 +176,14 @@ export class MeetingService { * 重複は警告(作成ブロックしない)。時間重複 = NOT(existing.end <= new.start OR existing.start >= new.end) */ checkScheduleConflicts( + actorId: number, projectId: number, memberIds: number[], startAt: string, endAt: string, excludeMeetingId?: number ): ScheduleConflict[] { + this.requireMember(projectId, actorId); const conflicts: ScheduleConflict[] = []; const newStart = new Date(startAt).getTime(); const newEnd = new Date(endAt).getTime(); diff --git a/services/SearchService.ts b/services/SearchService.ts index 206fca5..c27c8f0 100644 --- a/services/SearchService.ts +++ b/services/SearchService.ts @@ -31,6 +31,9 @@ export interface SearchOptions { type?: SearchResourceType; } +// 検索は全件対象とする(ページネーション既定値による検索漏れを防ぐ) +const SEARCH_MAX = Number.MAX_SAFE_INTEGER; + /** * プロジェクト内の横断検索を担うService。 * 各リソースを取得しキーワードで絞り込む(小規模データ前提)。 @@ -62,7 +65,10 @@ export class SearchService { !!text && text.toLowerCase().includes(q); if (!type || type === 'thread') { - for (const t of this.boardRepository.findThreads(projectId).items) { + for (const t of this.boardRepository.findThreads(projectId, { + page: 1, + pageSize: SEARCH_MAX, + }).items) { if (match(t.title) || match(t.bodyMd)) { results.push({ type: 'thread', @@ -74,7 +80,10 @@ export class SearchService { } } if (!type || type === 'chat') { - for (const m of this.chatRepository.findMessages(projectId).items) { + for (const m of this.chatRepository.findMessages(projectId, { + page: 1, + pageSize: SEARCH_MAX, + }).items) { if (match(m.body)) { results.push({ type: 'chat', @@ -101,7 +110,7 @@ export class SearchService { const files = this.fileRepository.findFilesByProject( projectId, 1, - 500 + SEARCH_MAX ).items; for (const f of files) { if (match(f.originalName)) { @@ -156,7 +165,10 @@ export class SearchService { } } if (!type || type === 'note') { - for (const n of this.noteRepository.findNotes(projectId).items) { + for (const n of this.noteRepository.findNotes(projectId, { + page: 1, + pageSize: SEARCH_MAX, + }).items) { if (match(n.title) || match(n.bodyMd) || match(n.tags)) { results.push({ type: 'note', diff --git a/tests/e2e/activity-log.spec.ts b/tests/e2e/activity-log.spec.ts new file mode 100644 index 0000000..c2cd46c --- /dev/null +++ b/tests/e2e/activity-log.spec.ts @@ -0,0 +1,49 @@ +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('activity log', () => { + test('board post and todo creation are recorded in the project activity log', async ({ + page, + }) => { + const projectId = await setupOwner(page); + + // 掲示板投稿 → board_posted アクティビティ + await page.request.post(`/api/projects/${projectId}/board/threads`, { + data: { title: unique('Thread'), bodyMd: 'body', category: 'notice' }, + }); + // ToDo作成 → todo_created アクティビティ + const cols = ( + (await ( + await page.request.get(`/api/projects/${projectId}/todos/columns`) + ).json()) as { + columns: { id: number }[]; + } + ).columns; + await page.request.post(`/api/projects/${projectId}/todos/items`, { + data: { title: unique('Task'), columnId: cols[0].id }, + }); + + // アクティビティ画面に両方のアクションが記録されている + await page.goto(`/projects/${projectId}/activity`); + await expect(page.getByText('掲示板投稿')).toBeVisible(); + await expect(page.getByText('ToDo作成')).toBeVisible(); + }); +}); diff --git a/tests/e2e/notifications.spec.ts b/tests/e2e/notifications.spec.ts new file mode 100644 index 0000000..a477cc5 --- /dev/null +++ b/tests/e2e/notifications.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, type Page } from '@playwright/test'; + +function unique(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function registerAndLogin(page: Page, email: string, name: string) { + await page.goto('/login'); + await page.getByRole('button', { name: '新規登録はこちら' }).click(); + await page.getByLabel('表示名').fill(name); + await page.getByLabel('メールアドレス').fill(email); + await page.getByLabel('パスワード').fill('password123'); + await page.getByRole('button', { name: '登録する' }).click(); + await expect(page).toHaveURL(/\/dashboard/); +} + +test.describe('notifications', () => { + test('member-added notification appears and can be marked read', async ({ + browser, + }) => { + const ownerEmail = unique('owner') + '@example.com'; + const memberEmail = unique('member') + '@example.com'; + + // メンバーを先に登録(UIで作成+ログイン) + const memberContext = await browser.newContext(); + const memberPage = await memberContext.newPage(); + await registerAndLogin(memberPage, memberEmail, 'Member'); + + // オーナー登録+プロジェクト作成 + const ownerContext = await browser.newContext(); + const ownerPage = await ownerContext.newPage(); + await registerAndLogin(ownerPage, ownerEmail, 'Owner'); + await ownerPage.getByLabel('プロジェクト名').fill(unique('Proj')); + await ownerPage.getByRole('button', { name: '新規プロジェクト' }).click(); + await expect(ownerPage).toHaveURL(/\/projects\/\d+$/); + const projectId = Number(ownerPage.url().match(/\/projects\/(\d+)/)![1]); + + // メンバー追加 → project_added 通知がメンバーへ + const addRes = await ownerPage.request.post( + `/api/projects/${projectId}/members`, + { data: { email: memberEmail, role: 'member' } } + ); + expect(addRes.ok()).toBeTruthy(); + + // メンバーの通知一覧に表示 + await memberPage.goto('/notifications'); + await expect( + memberPage.getByText('プロジェクトに追加されました') + ).toBeVisible(); + + // 既読化(API) → 一覧が空になる + const listRes = await memberPage.request.get('/api/notifications?page=1'); + const { items } = (await listRes.json()) as { + items: { id: number }[]; + }; + expect(items).toHaveLength(1); + const readRes = await memberPage.request.post( + `/api/notifications/${items[0].id}/read` + ); + expect(readRes.ok()).toBeTruthy(); + await memberPage.reload(); + await expect( + memberPage.getByText('未読の通知はありません。') + ).toBeVisible(); + + await ownerContext.close(); + await memberContext.close(); + }); +}); diff --git a/tests/unit/services/MeetingService.test.ts b/tests/unit/services/MeetingService.test.ts index 7c01f9a..07165e6 100644 --- a/tests/unit/services/MeetingService.test.ts +++ b/tests/unit/services/MeetingService.test.ts @@ -91,6 +91,7 @@ describe('MeetingService', () => { }).meeting; // 新しいミーティング(10:30-11:30)は重複 const conflicts = service.checkScheduleConflicts( + authorId, projectId, [memberId], '2026-06-15T10:30:00', @@ -109,6 +110,7 @@ describe('MeetingService', () => { memberIds: [memberId], }); const conflicts = service.checkScheduleConflicts( + authorId, projectId, [memberId], '2026-06-15T11:00:00', @@ -125,6 +127,7 @@ describe('MeetingService', () => { memberIds: [memberId], }).meeting; const conflicts = service.checkScheduleConflicts( + authorId, projectId, [memberId], '2026-06-15T10:30:00', @@ -144,6 +147,7 @@ describe('MeetingService', () => { createdById: memberId, }); const conflicts = service.checkScheduleConflicts( + authorId, projectId, [memberId], '2026-06-15T10:30:00', @@ -169,6 +173,7 @@ describe('MeetingService', () => { orderIndex: 0, }); const conflicts = service.checkScheduleConflicts( + authorId, projectId, [memberId], '2026-06-15T10:00:00', @@ -194,6 +199,7 @@ describe('MeetingService', () => { orderIndex: 0, }); const conflicts = service.checkScheduleConflicts( + authorId, projectId, [memberId], '2026-06-15T10:00:00',