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:
Ken Yasue
2026-06-25 01:36:10 +02:00
parent fb61a7f592
commit e45aea8e27
13 changed files with 1116 additions and 1 deletions

View 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);
});
});

View 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);
});
});

View 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);
});
});