Merge branch 'feature/m15-full-tests' - M15 full test completion & quality
This commit is contained in:
@ -61,6 +61,7 @@ export async function POST(
|
|||||||
const service = createMeetingService();
|
const service = createMeetingService();
|
||||||
try {
|
try {
|
||||||
const conflicts = service.checkScheduleConflicts(
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
user.id,
|
||||||
Number(projectId),
|
Number(projectId),
|
||||||
Array.isArray(body.memberIds) ? body.memberIds.map(Number) : [],
|
Array.isArray(body.memberIds) ? body.memberIds.map(Number) : [],
|
||||||
String(body.startAt ?? ''),
|
String(body.startAt ?? ''),
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 1,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
globalSetup: './tests/e2e/globalSetup.ts',
|
globalSetup: './tests/e2e/globalSetup.ts',
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
@ -75,6 +75,7 @@ export class MeetingService {
|
|||||||
this.requireMember(projectId, actorId);
|
this.requireMember(projectId, actorId);
|
||||||
this.validate(input);
|
this.validate(input);
|
||||||
const conflicts = this.checkScheduleConflicts(
|
const conflicts = this.checkScheduleConflicts(
|
||||||
|
actorId,
|
||||||
projectId,
|
projectId,
|
||||||
input.memberIds,
|
input.memberIds,
|
||||||
input.startAt,
|
input.startAt,
|
||||||
@ -175,12 +176,14 @@ export class MeetingService {
|
|||||||
* 重複は警告(作成ブロックしない)。時間重複 = NOT(existing.end <= new.start OR existing.start >= new.end)
|
* 重複は警告(作成ブロックしない)。時間重複 = NOT(existing.end <= new.start OR existing.start >= new.end)
|
||||||
*/
|
*/
|
||||||
checkScheduleConflicts(
|
checkScheduleConflicts(
|
||||||
|
actorId: number,
|
||||||
projectId: number,
|
projectId: number,
|
||||||
memberIds: number[],
|
memberIds: number[],
|
||||||
startAt: string,
|
startAt: string,
|
||||||
endAt: string,
|
endAt: string,
|
||||||
excludeMeetingId?: number
|
excludeMeetingId?: number
|
||||||
): ScheduleConflict[] {
|
): ScheduleConflict[] {
|
||||||
|
this.requireMember(projectId, actorId);
|
||||||
const conflicts: ScheduleConflict[] = [];
|
const conflicts: ScheduleConflict[] = [];
|
||||||
const newStart = new Date(startAt).getTime();
|
const newStart = new Date(startAt).getTime();
|
||||||
const newEnd = new Date(endAt).getTime();
|
const newEnd = new Date(endAt).getTime();
|
||||||
|
|||||||
@ -31,6 +31,9 @@ export interface SearchOptions {
|
|||||||
type?: SearchResourceType;
|
type?: SearchResourceType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 検索は全件対象とする(ページネーション既定値による検索漏れを防ぐ)
|
||||||
|
const SEARCH_MAX = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* プロジェクト内の横断検索を担うService。
|
* プロジェクト内の横断検索を担うService。
|
||||||
* 各リソースを取得しキーワードで絞り込む(小規模データ前提)。
|
* 各リソースを取得しキーワードで絞り込む(小規模データ前提)。
|
||||||
@ -62,7 +65,10 @@ export class SearchService {
|
|||||||
!!text && text.toLowerCase().includes(q);
|
!!text && text.toLowerCase().includes(q);
|
||||||
|
|
||||||
if (!type || type === 'thread') {
|
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)) {
|
if (match(t.title) || match(t.bodyMd)) {
|
||||||
results.push({
|
results.push({
|
||||||
type: 'thread',
|
type: 'thread',
|
||||||
@ -74,7 +80,10 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!type || type === 'chat') {
|
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)) {
|
if (match(m.body)) {
|
||||||
results.push({
|
results.push({
|
||||||
type: 'chat',
|
type: 'chat',
|
||||||
@ -101,7 +110,7 @@ export class SearchService {
|
|||||||
const files = this.fileRepository.findFilesByProject(
|
const files = this.fileRepository.findFilesByProject(
|
||||||
projectId,
|
projectId,
|
||||||
1,
|
1,
|
||||||
500
|
SEARCH_MAX
|
||||||
).items;
|
).items;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (match(f.originalName)) {
|
if (match(f.originalName)) {
|
||||||
@ -156,7 +165,10 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!type || type === 'note') {
|
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)) {
|
if (match(n.title) || match(n.bodyMd) || match(n.tags)) {
|
||||||
results.push({
|
results.push({
|
||||||
type: 'note',
|
type: 'note',
|
||||||
|
|||||||
49
tests/e2e/activity-log.spec.ts
Normal file
49
tests/e2e/activity-log.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
69
tests/e2e/notifications.spec.ts
Normal file
69
tests/e2e/notifications.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -91,6 +91,7 @@ describe('MeetingService', () => {
|
|||||||
}).meeting;
|
}).meeting;
|
||||||
// 新しいミーティング(10:30-11:30)は重複
|
// 新しいミーティング(10:30-11:30)は重複
|
||||||
const conflicts = service.checkScheduleConflicts(
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
authorId,
|
||||||
projectId,
|
projectId,
|
||||||
[memberId],
|
[memberId],
|
||||||
'2026-06-15T10:30:00',
|
'2026-06-15T10:30:00',
|
||||||
@ -109,6 +110,7 @@ describe('MeetingService', () => {
|
|||||||
memberIds: [memberId],
|
memberIds: [memberId],
|
||||||
});
|
});
|
||||||
const conflicts = service.checkScheduleConflicts(
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
authorId,
|
||||||
projectId,
|
projectId,
|
||||||
[memberId],
|
[memberId],
|
||||||
'2026-06-15T11:00:00',
|
'2026-06-15T11:00:00',
|
||||||
@ -125,6 +127,7 @@ describe('MeetingService', () => {
|
|||||||
memberIds: [memberId],
|
memberIds: [memberId],
|
||||||
}).meeting;
|
}).meeting;
|
||||||
const conflicts = service.checkScheduleConflicts(
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
authorId,
|
||||||
projectId,
|
projectId,
|
||||||
[memberId],
|
[memberId],
|
||||||
'2026-06-15T10:30:00',
|
'2026-06-15T10:30:00',
|
||||||
@ -144,6 +147,7 @@ describe('MeetingService', () => {
|
|||||||
createdById: memberId,
|
createdById: memberId,
|
||||||
});
|
});
|
||||||
const conflicts = service.checkScheduleConflicts(
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
authorId,
|
||||||
projectId,
|
projectId,
|
||||||
[memberId],
|
[memberId],
|
||||||
'2026-06-15T10:30:00',
|
'2026-06-15T10:30:00',
|
||||||
@ -169,6 +173,7 @@ describe('MeetingService', () => {
|
|||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
});
|
});
|
||||||
const conflicts = service.checkScheduleConflicts(
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
authorId,
|
||||||
projectId,
|
projectId,
|
||||||
[memberId],
|
[memberId],
|
||||||
'2026-06-15T10:00:00',
|
'2026-06-15T10:00:00',
|
||||||
@ -194,6 +199,7 @@ describe('MeetingService', () => {
|
|||||||
orderIndex: 0,
|
orderIndex: 0,
|
||||||
});
|
});
|
||||||
const conflicts = service.checkScheduleConflicts(
|
const conflicts = service.checkScheduleConflicts(
|
||||||
|
authorId,
|
||||||
projectId,
|
projectId,
|
||||||
[memberId],
|
[memberId],
|
||||||
'2026-06-15T10:00:00',
|
'2026-06-15T10:00:00',
|
||||||
|
|||||||
Reference in New Issue
Block a user