feat(m7): markdown notes with CRUD, preview, search, tests, e2e
- ProjectNoteRepository (CRUD, soft-delete, search title/body/tags, pinned-first ordering, index usage) + NoteService (membership permission, author/admin edit/delete, note_updated notification to other members, note_created/note_updated activity logs, pin/tags) - APIs: notes list/create/detail/edit/delete - Screens: notes list + NoteForm, note detail + NoteEditor + MarkdownBody preview, ProjectNav メモ link - Unit tests (ProjectNoteRepository, NoteService) + e2e markdown-notes.spec
This commit is contained in:
76
tests/e2e/markdown-notes.spec.ts
Normal file
76
tests/e2e/markdown-notes.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
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('markdown notes', () => {
|
||||
test('create, preview, pin, edit, and search notes', async ({ page }) => {
|
||||
const projectId = await setupOwner(page);
|
||||
const title = unique('Note');
|
||||
|
||||
const res = await page.request.post(`/api/projects/${projectId}/notes`, {
|
||||
data: {
|
||||
title,
|
||||
bodyMd: '# Heading\n\n- item1\n- item2',
|
||||
tags: 'alpha,beta',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { note } = (await res.json()) as { note: { id: number } };
|
||||
|
||||
// 一覧に表示
|
||||
await page.goto(`/projects/${projectId}/notes`);
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(title) })
|
||||
).toBeVisible();
|
||||
|
||||
// 詳細: Markdownがレンダリングされる
|
||||
await page.goto(`/projects/${projectId}/notes/${note.id}`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Heading', level: 1 })
|
||||
).toBeVisible();
|
||||
await expect(page.locator('li').filter({ hasText: 'item1' })).toBeVisible();
|
||||
|
||||
// ピン留め + 編集(API)
|
||||
const patchRes = await page.request.patch(
|
||||
`/api/projects/${projectId}/notes/${note.id}`,
|
||||
{ data: { isPinned: 1, bodyMd: '# Updated\n\nedited content' } }
|
||||
);
|
||||
expect(patchRes.ok()).toBeTruthy();
|
||||
await page.goto(`/projects/${projectId}/notes/${note.id}`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Updated', level: 1 })
|
||||
).toBeVisible();
|
||||
|
||||
// 検索
|
||||
const otherTitle = unique('ZZZ');
|
||||
await page.request.post(`/api/projects/${projectId}/notes`, {
|
||||
data: { title: otherTitle, bodyMd: 'body' },
|
||||
});
|
||||
await page.goto(
|
||||
`/projects/${projectId}/notes?q=${encodeURIComponent(otherTitle)}`
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(otherTitle) })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('link', { name: new RegExp(title) })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
111
tests/unit/repositories/ProjectNoteRepository.test.ts
Normal file
111
tests/unit/repositories/ProjectNoteRepository.test.ts
Normal file
@ -0,0 +1,111 @@
|
||||
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 { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
|
||||
describe('ProjectNoteRepository', () => {
|
||||
let db: SqliteDatabase;
|
||||
let repo: ProjectNoteRepository;
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
repo = new ProjectNoteRepository(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 createNote(title: string, tags: string | null = null) {
|
||||
return repo.create({
|
||||
projectId,
|
||||
title,
|
||||
bodyMd: 'body',
|
||||
tags,
|
||||
createdById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
it('creates and finds a note', () => {
|
||||
const n = createNote('N1', 'tag1,tag2');
|
||||
expect(repo.findNoteById(n.id)?.title).toBe('N1');
|
||||
expect(repo.findNoteById(n.id)?.tags).toBe('tag1,tag2');
|
||||
});
|
||||
|
||||
it('lists notes pinned-first then recently updated', () => {
|
||||
const a = createNote('a');
|
||||
const b = createNote('b');
|
||||
repo.update(a.id, { isPinned: 1, updatedById: userId });
|
||||
|
||||
const { items } = repo.findNotes(projectId);
|
||||
expect(items[0].id).toBe(a.id);
|
||||
expect(items[1].id).toBe(b.id);
|
||||
});
|
||||
|
||||
it('excludes soft-deleted notes', () => {
|
||||
const n = createNote('N');
|
||||
repo.delete(n.id);
|
||||
expect(repo.findNoteById(n.id)).toBeNull();
|
||||
expect(repo.findNotes(projectId).total).toBe(0);
|
||||
});
|
||||
|
||||
it('isolates notes by project', () => {
|
||||
createNote('mine');
|
||||
const p2 = new ProjectRepository(db).create({
|
||||
name: 'P2',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
repo.create({
|
||||
projectId: p2,
|
||||
title: 'theirs',
|
||||
bodyMd: 'b',
|
||||
tags: null,
|
||||
createdById: userId,
|
||||
});
|
||||
expect(repo.findNotes(projectId).total).toBe(1);
|
||||
expect(repo.findNotes(p2).total).toBe(1);
|
||||
});
|
||||
|
||||
it('searches notes by title/body/tags', () => {
|
||||
createNote('Alpha', 'x');
|
||||
createNote('Beta', 'special');
|
||||
expect(repo.findNotes(projectId, { search: 'alpha' }).total).toBe(1);
|
||||
expect(repo.findNotes(projectId, { search: 'special' }).total).toBe(1);
|
||||
expect(repo.findNotes(projectId, { search: 'nomatch' }).total).toBe(0);
|
||||
});
|
||||
|
||||
it('paginates notes', () => {
|
||||
for (let i = 0; i < 5; i++) createNote(`n${i}`);
|
||||
expect(
|
||||
repo.findNotes(projectId, { page: 1, pageSize: 2 }).items
|
||||
).toHaveLength(2);
|
||||
expect(repo.findNotes(projectId, { page: 1, pageSize: 2 }).total).toBe(5);
|
||||
});
|
||||
|
||||
it('updates note fields and updatedById', () => {
|
||||
const n = createNote('N');
|
||||
const updated = repo.update(n.id, {
|
||||
title: 'N2',
|
||||
bodyMd: 'new',
|
||||
isPinned: 1,
|
||||
updatedById: userId,
|
||||
});
|
||||
expect(updated?.title).toBe('N2');
|
||||
expect(updated?.bodyMd).toBe('new');
|
||||
expect(updated?.isPinned).toBe(1);
|
||||
expect(updated?.updatedById).toBe(userId);
|
||||
});
|
||||
});
|
||||
133
tests/unit/services/NoteService.test.ts
Normal file
133
tests/unit/services/NoteService.test.ts
Normal file
@ -0,0 +1,133 @@
|
||||
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 { ProjectNoteRepository } from '@/repositories/ProjectNoteRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { NoteService } from '@/services/NoteService';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
function makeService(db: SqliteDatabase) {
|
||||
return new NoteService(
|
||||
new ProjectNoteRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
);
|
||||
}
|
||||
|
||||
describe('NoteService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let service: NoteService;
|
||||
let projectId: number;
|
||||
let authorId: number;
|
||||
let memberId: number;
|
||||
let outsiderId: 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;
|
||||
outsiderId = users.create({
|
||||
name: 'O',
|
||||
email: 'o@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 note and records note_created activity', () => {
|
||||
const note = service.createNote(memberId, projectId, {
|
||||
title: 'N',
|
||||
bodyMd: 'body',
|
||||
tags: 't1',
|
||||
});
|
||||
expect(note.title).toBe('N');
|
||||
expect(
|
||||
new ActivityLogRepository(db)
|
||||
.findByProject(projectId)
|
||||
.items.some((l) => l.action === 'note_created')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('forbids a non-member from creating a note', () => {
|
||||
expect(() =>
|
||||
service.createNote(outsiderId, projectId, { title: 'N', bodyMd: 'b' })
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('rejects an empty title', () => {
|
||||
expect(() =>
|
||||
service.createNote(memberId, projectId, { title: ' ', bodyMd: 'b' })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('on update, notifies other project members and logs note_updated', () => {
|
||||
const note = service.createNote(authorId, projectId, {
|
||||
title: 'N',
|
||||
bodyMd: 'b',
|
||||
});
|
||||
service.updateNote(authorId, note.id, { bodyMd: 'edited by author' });
|
||||
|
||||
// 他のメンバー(member)は更新通知を受ける
|
||||
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1);
|
||||
// 操作者(author)自身には通知しない
|
||||
expect(new NotificationRepository(db).countUnreadByUser(authorId)).toBe(0);
|
||||
expect(
|
||||
new ActivityLogRepository(db)
|
||||
.findByProject(projectId)
|
||||
.items.some((l) => l.action === 'note_updated')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('only author or admin can edit/delete', () => {
|
||||
const users = new UserRepository(db);
|
||||
const member2 = users.create({
|
||||
name: 'M2',
|
||||
email: 'm2@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
new ProjectMemberRepository(db).add(projectId, member2, 'member');
|
||||
const note = service.createNote(memberId, projectId, {
|
||||
title: 'N',
|
||||
bodyMd: 'b',
|
||||
});
|
||||
|
||||
expect(() => service.updateNote(member2, note.id, { title: 'X' })).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
expect(() =>
|
||||
service.updateNote(authorId, note.id, { title: 'X' })
|
||||
).not.toThrow(); // admin
|
||||
expect(() =>
|
||||
service.updateNote(memberId, note.id, { title: 'Y' })
|
||||
).not.toThrow(); // author
|
||||
});
|
||||
|
||||
it('throws NotFoundError for a non-existent note', () => {
|
||||
expect(() => service.getNote(memberId, 99999)).toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user