feat(m13): cross-resource search + dashboard completion, tests, e2e
- SearchService (cross-resource keyword search across board/chat/todo/file/ event/meeting/milestone/note with type filter, project isolation) - DashboardService (project dashboard: in-progress todos, near-due(7d), latest chat/board/notes/files(5), next meeting, milestones w/ progress, recent activity(10); personal dashboard: my projects, incomplete todos, upcoming meetings, unread notifications, overdue tasks, recent activity) - CalendarRepository.findByProject - APIs: GET /api/projects/:projectId/search, GET /api/dashboard - Screens: complete personal dashboard, complete project overview, search page + SearchForm, ProjectNav 検索 link - Unit tests (SearchService, DashboardService) + e2e dashboard
This commit is contained in:
85
tests/e2e/dashboard.spec.ts
Normal file
85
tests/e2e/dashboard.spec.ts
Normal file
@ -0,0 +1,85 @@
|
||||
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('dashboard & search', () => {
|
||||
test('personal dashboard, project dashboard, and search', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await setupOwner(page);
|
||||
const keyword = unique('unicorn');
|
||||
|
||||
// データ作成: 掲示板スレッド + ToDo
|
||||
await page.request.post(`/api/projects/${projectId}/board/threads`, {
|
||||
data: { title: `thread ${keyword}`, bodyMd: 'body', category: 'notice' },
|
||||
});
|
||||
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: `task ${keyword}`,
|
||||
columnId: cols[0].id,
|
||||
assigneeId: undefined,
|
||||
},
|
||||
});
|
||||
// 自分(owner)のIDを取得してToDo担当にする
|
||||
const me = (await (await page.request.get('/api/auth/me')).json()) as {
|
||||
user: { id: number };
|
||||
};
|
||||
const items = (
|
||||
(await (
|
||||
await page.request.get(`/api/projects/${projectId}/todos/items`)
|
||||
).json()) as {
|
||||
items: { id: number; title: string }[];
|
||||
}
|
||||
).items;
|
||||
const taskId = items.find((i) => i.title.includes(keyword))!.id;
|
||||
await page.request.patch(
|
||||
`/api/projects/${projectId}/todos/items/${taskId}`,
|
||||
{
|
||||
data: { assigneeId: me.user.id },
|
||||
}
|
||||
);
|
||||
|
||||
// 個人ダッシュボード: プロジェクト + 未完了ToDo
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByText(unique('Proj').split('-')[0])).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText(`task ${keyword}`)).toBeVisible();
|
||||
|
||||
// プロジェクトダッシュボード: 進行中ToDo + 最新掲示板
|
||||
await page.goto(`/projects/${projectId}`);
|
||||
await expect(page.getByText(`task ${keyword}`)).toBeVisible();
|
||||
await expect(page.getByText(`thread ${keyword}`)).toBeVisible();
|
||||
|
||||
// 検索
|
||||
await page.goto(
|
||||
`/projects/${projectId}/search?q=${encodeURIComponent(keyword)}`
|
||||
);
|
||||
await expect(page.getByText(`thread ${keyword}`)).toBeVisible();
|
||||
await expect(page.getByText(`task ${keyword}`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
180
tests/unit/services/DashboardService.test.ts
Normal file
180
tests/unit/services/DashboardService.test.ts
Normal file
@ -0,0 +1,180 @@
|
||||
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 { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { ScheduleService } from '@/services/ScheduleService';
|
||||
import { DashboardService } from '@/services/DashboardService';
|
||||
import { ForbiddenError } from '@/lib/errors';
|
||||
|
||||
function makeServices(db: SqliteDatabase) {
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
const scheduleService = new ScheduleService(
|
||||
new CalendarRepository(db),
|
||||
new MilestoneRepository(db),
|
||||
new TodoRepository(db),
|
||||
memberRepo,
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
);
|
||||
const dashboard = new DashboardService(
|
||||
new ProjectRepository(db),
|
||||
new TodoRepository(db),
|
||||
new ChatRepository(db),
|
||||
new BoardRepository(db),
|
||||
new ProjectNoteRepository(db),
|
||||
new FileRepository(db),
|
||||
new MeetingRepository(db),
|
||||
new ActivityLogRepository(db),
|
||||
new NotificationRepository(db),
|
||||
memberRepo,
|
||||
scheduleService
|
||||
);
|
||||
return { dashboard, memberRepo };
|
||||
}
|
||||
|
||||
describe('DashboardService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let dashboard: DashboardService;
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
let outsiderId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
const ctx = makeServices(db);
|
||||
dashboard = ctx.dashboard;
|
||||
const users = new UserRepository(db);
|
||||
userId = users.create({
|
||||
name: 'U',
|
||||
email: 'u@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
outsiderId = users.create({
|
||||
name: 'O',
|
||||
email: 'o@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
ctx.memberRepo.add(projectId, userId, 'admin');
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it('getProjectDashboard aggregates the project resources', () => {
|
||||
const col = new TodoRepository(db).createColumn({
|
||||
projectId,
|
||||
name: 'Todo',
|
||||
orderIndex: 0,
|
||||
}).id;
|
||||
const todoRepo = new TodoRepository(db);
|
||||
todoRepo.createItem({
|
||||
projectId,
|
||||
columnId: col,
|
||||
title: 't1',
|
||||
creatorId: userId,
|
||||
orderIndex: 0,
|
||||
dueDate: '2099-01-01',
|
||||
});
|
||||
todoRepo.createItem({
|
||||
projectId,
|
||||
columnId: col,
|
||||
title: 'done',
|
||||
creatorId: userId,
|
||||
orderIndex: 1,
|
||||
});
|
||||
todoRepo.updateItem(2, { completedAt: '2099-01-01T00:00:00.000Z' });
|
||||
new ChatRepository(db).create({ projectId, authorId: userId, body: 'hi' });
|
||||
new BoardRepository(db).createThread({
|
||||
projectId,
|
||||
title: 'thread',
|
||||
bodyMd: 'b',
|
||||
authorId: userId,
|
||||
category: null,
|
||||
});
|
||||
new ProjectNoteRepository(db).create({
|
||||
projectId,
|
||||
title: 'note',
|
||||
bodyMd: 'b',
|
||||
tags: null,
|
||||
createdById: userId,
|
||||
});
|
||||
new FileRepository(db).create({
|
||||
projectId,
|
||||
uploaderId: userId,
|
||||
filename: 'a.bin',
|
||||
originalName: 'a',
|
||||
mimeType: 'text/plain',
|
||||
size: 1,
|
||||
path: '/tmp/a',
|
||||
});
|
||||
new MeetingRepository(db).create({
|
||||
projectId,
|
||||
title: 'future',
|
||||
startAt: '2099-06-15T10:00:00',
|
||||
endAt: '2099-06-15T11:00:00',
|
||||
createdById: userId,
|
||||
});
|
||||
new MilestoneRepository(db).create({ projectId, title: 'M' });
|
||||
|
||||
const d = dashboard.getProjectDashboard(userId, projectId);
|
||||
expect(d.project.id).toBe(projectId);
|
||||
expect(d.inProgressTodos).toHaveLength(1); // t1 not completed, 'done' is completed
|
||||
expect(d.latestChat).toHaveLength(1);
|
||||
expect(d.latestBoard).toHaveLength(1);
|
||||
expect(d.latestNotes).toHaveLength(1);
|
||||
expect(d.recentFiles).toHaveLength(1);
|
||||
expect(d.nextMeeting?.title).toBe('future');
|
||||
expect(d.milestones).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getProjectDashboard forbids a non-member', () => {
|
||||
expect(() => dashboard.getProjectDashboard(outsiderId, projectId)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
});
|
||||
|
||||
it('getPersonalDashboard returns my projects, todos, notifications', () => {
|
||||
const col = new TodoRepository(db).createColumn({
|
||||
projectId,
|
||||
name: 'Todo',
|
||||
orderIndex: 0,
|
||||
}).id;
|
||||
new TodoRepository(db).createItem({
|
||||
projectId,
|
||||
columnId: col,
|
||||
title: 'mine',
|
||||
creatorId: userId,
|
||||
assigneeId: userId,
|
||||
orderIndex: 0,
|
||||
dueDate: '2099-01-01',
|
||||
});
|
||||
new NotificationRepository(db).create({
|
||||
userId,
|
||||
projectId,
|
||||
type: 'mention',
|
||||
title: 'n',
|
||||
body: null,
|
||||
});
|
||||
|
||||
const d = dashboard.getPersonalDashboard(userId);
|
||||
expect(d.projects.map((p) => p.id)).toContain(projectId);
|
||||
expect(d.incompleteTodos).toHaveLength(1);
|
||||
expect(d.unreadNotificationCount).toBe(1);
|
||||
expect(d.overdueTasks).toHaveLength(0); // due 2099, not overdue
|
||||
});
|
||||
});
|
||||
161
tests/unit/services/SearchService.test.ts
Normal file
161
tests/unit/services/SearchService.test.ts
Normal file
@ -0,0 +1,161 @@
|
||||
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 { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||
import { MeetingRepository } from '@/repositories/MeetingRepository';
|
||||
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||
import { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { SearchService } from '@/services/SearchService';
|
||||
import { ForbiddenError } from '@/lib/errors';
|
||||
|
||||
function makeService(db: SqliteDatabase) {
|
||||
return new SearchService(
|
||||
new BoardRepository(db),
|
||||
new ChatRepository(db),
|
||||
new TodoRepository(db),
|
||||
new FileRepository(db),
|
||||
new CalendarRepository(db),
|
||||
new MeetingRepository(db),
|
||||
new MilestoneRepository(db),
|
||||
new ProjectNoteRepository(db),
|
||||
new ProjectMemberRepository(db)
|
||||
);
|
||||
}
|
||||
|
||||
describe('SearchService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let service: SearchService;
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
let outsiderId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
service = makeService(db);
|
||||
const users = new UserRepository(db);
|
||||
userId = users.create({
|
||||
name: 'U',
|
||||
email: 'u@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
outsiderId = users.create({
|
||||
name: 'O',
|
||||
email: 'o@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
const members = new ProjectMemberRepository(db);
|
||||
members.add(projectId, userId, 'admin');
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
function seedKeyword(keyword: string) {
|
||||
const col = new TodoRepository(db).createColumn({
|
||||
projectId,
|
||||
name: 'Todo',
|
||||
orderIndex: 0,
|
||||
}).id;
|
||||
new BoardRepository(db).createThread({
|
||||
projectId,
|
||||
title: `thread ${keyword}`,
|
||||
bodyMd: 'body',
|
||||
authorId: userId,
|
||||
category: null,
|
||||
});
|
||||
new ChatRepository(db).create({
|
||||
projectId,
|
||||
authorId: userId,
|
||||
body: `chat ${keyword}`,
|
||||
});
|
||||
new TodoRepository(db).createItem({
|
||||
projectId,
|
||||
columnId: col,
|
||||
title: `task ${keyword}`,
|
||||
creatorId: userId,
|
||||
orderIndex: 0,
|
||||
});
|
||||
new FileRepository(db).create({
|
||||
projectId,
|
||||
uploaderId: userId,
|
||||
filename: 'a.bin',
|
||||
originalName: `file-${keyword}.txt`,
|
||||
mimeType: 'text/plain',
|
||||
size: 1,
|
||||
path: '/tmp/a',
|
||||
});
|
||||
new CalendarRepository(db).create({
|
||||
projectId,
|
||||
title: `event ${keyword}`,
|
||||
type: 'custom',
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
createdById: userId,
|
||||
});
|
||||
new MeetingRepository(db).create({
|
||||
projectId,
|
||||
title: `meeting ${keyword}`,
|
||||
startAt: '2026-06-15T10:00:00',
|
||||
endAt: '2026-06-15T11:00:00',
|
||||
createdById: userId,
|
||||
});
|
||||
new MilestoneRepository(db).create({ projectId, title: `ms ${keyword}` });
|
||||
new ProjectNoteRepository(db).create({
|
||||
projectId,
|
||||
title: `note ${keyword}`,
|
||||
bodyMd: 'body',
|
||||
tags: null,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
it('returns results across all resource types for a keyword', () => {
|
||||
seedKeyword('unicorn');
|
||||
const results = service.search(userId, projectId, { q: 'unicorn' });
|
||||
const types = new Set(results.map((r) => r.type));
|
||||
expect(types.has('thread')).toBe(true);
|
||||
expect(types.has('chat')).toBe(true);
|
||||
expect(types.has('todo')).toBe(true);
|
||||
expect(types.has('file')).toBe(true);
|
||||
expect(types.has('event')).toBe(true);
|
||||
expect(types.has('meeting')).toBe(true);
|
||||
expect(types.has('milestone')).toBe(true);
|
||||
expect(types.has('note')).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by a single type', () => {
|
||||
seedKeyword('unicorn');
|
||||
const results = service.search(userId, projectId, {
|
||||
q: 'unicorn',
|
||||
type: 'todo',
|
||||
});
|
||||
expect(results.every((r) => r.type === 'todo')).toBe(true);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns no results for an unmatched keyword', () => {
|
||||
seedKeyword('unicorn');
|
||||
expect(service.search(userId, projectId, { q: 'nomatch' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty query', () => {
|
||||
seedKeyword('unicorn');
|
||||
expect(service.search(userId, projectId, { q: '' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('forbids a non-member', () => {
|
||||
seedKeyword('unicorn');
|
||||
expect(() =>
|
||||
service.search(outsiderId, projectId, { q: 'unicorn' })
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user