diff --git a/app/api/files/[fileId]/download/route.ts b/app/api/files/[fileId]/download/route.ts new file mode 100644 index 0000000..2b446f5 --- /dev/null +++ b/app/api/files/[fileId]/download/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'node:fs'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createFileStorageService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ fileId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { fileId } = await params; + + const service = createFileStorageService(); + try { + const file = service.getFileInfo(user.id, Number(fileId)); + const body = fs.readFileSync(file.path); + return new NextResponse(body, { + headers: { + 'Content-Type': file.mimeType, + 'Content-Disposition': `inline; filename="${file.originalName}"`, + 'Content-Length': String(file.size), + }, + }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/files/[fileId]/route.ts b/app/api/files/[fileId]/route.ts new file mode 100644 index 0000000..c56203e --- /dev/null +++ b/app/api/files/[fileId]/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createFileStorageService } from '@/lib/api/services'; +import { UnauthorizedError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ fileId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { fileId } = await params; + + const service = createFileStorageService(); + try { + service.delete(user.id, Number(fileId)); + return NextResponse.json({ ok: true }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/projects/[projectId]/files/route.ts b/app/api/projects/[projectId]/files/route.ts new file mode 100644 index 0000000..85c90a8 --- /dev/null +++ b/app/api/projects/[projectId]/files/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCurrentUser } from '@/lib/auth/getCurrentUser'; +import { createFileStorageService } from '@/lib/api/services'; +import { UnauthorizedError, ValidationError } from '@/lib/errors'; +import { handleApiError } from '@/lib/api/handleError'; + +export const runtime = 'nodejs'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1; + const service = createFileStorageService(); + try { + return NextResponse.json( + service.listFiles(user.id, Number(projectId), page) + ); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + const user = await getCurrentUser(); + if (!user) return handleApiError(new UnauthorizedError()); + const { projectId } = await params; + + const formData = await request.formData(); + const file = formData.get('file'); + if (!(file instanceof File)) { + return handleApiError( + new ValidationError('ファイルを指定してください', 'file') + ); + } + + const data = Buffer.from(await file.arrayBuffer()); + const service = createFileStorageService(); + try { + const fileAsset = service.upload(user.id, Number(projectId), { + originalName: file.name, + mimeType: file.type || 'application/octet-stream', + data, + }); + return NextResponse.json({ file: fileAsset }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/projects/[projectId]/files/page.tsx b/app/projects/[projectId]/files/page.tsx new file mode 100644 index 0000000..48056e1 --- /dev/null +++ b/app/projects/[projectId]/files/page.tsx @@ -0,0 +1,52 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser'; +import { + createFileStorageService, + createProjectService, +} from '@/lib/api/services'; +import { Header } from '@/components/layout/Header'; +import { ProjectNav } from '@/components/layout/ProjectNav'; +import { Uploader } from '@/components/files/Uploader'; +import { FileList } from '@/components/files/FileList'; +import { ForbiddenError, NotFoundError } from '@/lib/errors'; + +export const dynamic = 'force-dynamic'; + +export default async function FilesPage({ + params, +}: { + params: Promise<{ projectId: string }>; +}) { + const user = await getCurrentUser(); + if (!user) redirect('/login'); + const { projectId } = await params; + + const projectService = createProjectService(); + let project: Awaited>; + try { + project = projectService.getProject(user.id, Number(projectId)); + } catch (error) { + if (error instanceof ForbiddenError || error instanceof NotFoundError) { + redirect('/dashboard'); + } + throw error; + } + + const fileService = createFileStorageService(); + const { items } = fileService.listFiles(user.id, project.id); + const canDelete = + projectService.getMemberRole(user.id, project.id) === 'admin' || + items.some((f) => f.uploaderId === user.id); + + return ( +
+
+ +
+

ファイル

+ + +
+
+ ); +} diff --git a/components/files/FileList.tsx b/components/files/FileList.tsx new file mode 100644 index 0000000..3834663 --- /dev/null +++ b/components/files/FileList.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import type { FileAsset } from '@/lib/types'; + +export function FileList({ + files, + canDelete, +}: { + files: FileAsset[]; + canDelete: boolean; +}) { + const router = useRouter(); + const [lightbox, setLightbox] = useState(null); + + async function onDelete(fileId: number) { + const res = await fetch(`/api/files/${fileId}`, { method: 'DELETE' }); + if (res.ok) router.refresh(); + } + + if (files.length === 0) { + return

ファイルはありません。

; + } + + return ( + <> + + + {lightbox && ( +
setLightbox(null)} + data-testid="lightbox" + > + {lightbox.originalName} +
+ )} + + ); +} diff --git a/components/files/Uploader.tsx b/components/files/Uploader.tsx new file mode 100644 index 0000000..a1c06be --- /dev/null +++ b/components/files/Uploader.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +export function Uploader({ projectId }: { projectId: number }) { + const router = useRouter(); + const inputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function onFile(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + setLoading(true); + setError(null); + const form = new FormData(); + form.append('file', file); + const res = await fetch(`/api/projects/${projectId}/files`, { + method: 'POST', + body: form, + }); + setLoading(false); + if (res.ok) { + if (inputRef.current) inputRef.current.value = ''; + router.refresh(); + } else { + const b = (await res.json().catch(() => null)) as { + error?: { message?: string }; + } | null; + setError(b?.error?.message ?? 'アップロードに失敗しました'); + } + } + + return ( +
+ + + {loading && ( +

アップロード中...

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/layout/ProjectNav.tsx b/components/layout/ProjectNav.tsx index 20c82c6..dcef510 100644 --- a/components/layout/ProjectNav.tsx +++ b/components/layout/ProjectNav.tsx @@ -4,6 +4,7 @@ const NAV_ITEMS = [ { href: '/notes', label: 'メモ' }, { href: '/chat', label: 'チャット' }, { href: '/todos', label: 'ToDo' }, + { href: '/files', label: 'ファイル' }, { href: '/members', label: 'メンバー' }, { href: '/activity', label: 'アクティビティ' }, { href: '/settings', label: '設定' }, @@ -24,6 +25,7 @@ export function ProjectNav({ | 'notes' | 'chat' | 'todos' + | 'files' | 'members' | 'activity' | 'settings'; @@ -34,6 +36,7 @@ export function ProjectNav({ notes: active === 'notes', chat: active === 'chat', todos: active === 'todos', + files: active === 'files', members: active === 'members', activity: active === 'activity', settings: active === 'settings', diff --git a/lib/api/services.ts b/lib/api/services.ts index 1ec8389..97b6bc0 100644 --- a/lib/api/services.ts +++ b/lib/api/services.ts @@ -16,6 +16,8 @@ import { ChatRepository } from '@/repositories/ChatRepository'; import { getSseHub } from '@/lib/sse/hub'; import { TodoService } from '@/services/TodoService'; import { TodoRepository } from '@/repositories/TodoRepository'; +import { FileStorageService } from '@/services/FileStorageService'; +import { FileRepository } from '@/repositories/FileRepository'; /** * Route Handler用に各Repository/Serviceを構築するファクトリ。 @@ -81,6 +83,19 @@ export function createTodoService(): TodoService { ); } +export function createFileStorageService(): FileStorageService { + const db = getDb(); + const uploadsDir = process.env.UPLOADS_PATH ?? './data/uploads'; + return new FileStorageService( + new FileRepository(db), + new ProjectMemberRepository(db), + new NotificationService(new NotificationRepository(db)), + new ActivityLogService(new ActivityLogRepository(db)), + getSseHub(), + uploadsDir + ); +} + export function createUserRepository(): UserRepository { return new UserRepository(getDb()); } diff --git a/repositories/FileRepository.ts b/repositories/FileRepository.ts new file mode 100644 index 0000000..a5b50b6 --- /dev/null +++ b/repositories/FileRepository.ts @@ -0,0 +1,108 @@ +import type { SqliteDatabase } from '@/lib/db/sqlite'; +import type { FileAsset } from '@/lib/types'; + +const DEFAULT_PAGE_SIZE = 20; + +export interface Paginated { + items: T[]; + total: number; +} + +interface FileAssetRow { + id: number; + project_id: number; + uploader_id: number; + filename: string; + original_name: string; + mime_type: string; + size: number; + path: string; + created_at: string; + deleted_at: string | null; +} + +function mapFile(row: FileAssetRow): FileAsset { + return { + id: row.id, + projectId: row.project_id, + uploaderId: row.uploader_id, + filename: row.filename, + originalName: row.original_name, + mimeType: row.mime_type, + size: row.size, + path: row.path, + createdAt: row.created_at, + deletedAt: row.deleted_at, + }; +} + +export interface CreateFileInput { + projectId: number; + uploaderId: number; + filename: string; + originalName: string; + mimeType: string; + size: number; + path: string; +} + +export class FileRepository { + constructor(private readonly db: SqliteDatabase) {} + + findFilesByProject( + projectId: number, + page: number = 1, + pageSize: number = DEFAULT_PAGE_SIZE + ): Paginated { + const offset = (page - 1) * pageSize; + const items = this.db.query( + `SELECT * FROM file_assets + WHERE project_id = @projectId AND deleted_at IS NULL + ORDER BY created_at DESC, id DESC + LIMIT @pageSize OFFSET @offset`, + { projectId, pageSize, offset } + ); + const total = this.db.get<{ count: number }>( + 'SELECT COUNT(*) AS count FROM file_assets WHERE project_id = @projectId AND deleted_at IS NULL', + { projectId } + ); + return { items: items.map(mapFile), total: total?.count ?? 0 }; + } + + findFileById(id: number): FileAsset | null { + const row = this.db.get( + 'SELECT * FROM file_assets WHERE id = @id AND deleted_at IS NULL', + { id } + ); + return row ? mapFile(row) : null; + } + + create(input: CreateFileInput): FileAsset { + const now = new Date().toISOString(); + const result = this.db.execute( + `INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at) + VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`, + { + projectId: input.projectId, + uploaderId: input.uploaderId, + filename: input.filename, + originalName: input.originalName, + mimeType: input.mimeType, + size: input.size, + path: input.path, + createdAt: now, + } + ); + const created = this.findFileById(Number(result.lastInsertRowid)); + if (!created) throw new Error('Failed to create file asset'); + return created; + } + + delete(id: number): boolean { + const result = this.db.execute( + 'UPDATE file_assets SET deleted_at = @now WHERE id = @id AND deleted_at IS NULL', + { now: new Date().toISOString(), id } + ); + return result.changes > 0; + } +} diff --git a/services/FileStorageService.ts b/services/FileStorageService.ts new file mode 100644 index 0000000..3c25fac --- /dev/null +++ b/services/FileStorageService.ts @@ -0,0 +1,175 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { FileRepository } from '@/repositories/FileRepository'; +import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository'; +import { NotificationService } from '@/services/NotificationService'; +import { ActivityLogService } from '@/services/ActivityLogService'; +import { SseHub } from '@/lib/sse/hub'; +import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; +import type { FileAsset } from '@/lib/types'; + +const ALLOWED_MIME_PREFIXES = [ + 'image/', + 'text/', + 'application/pdf', + 'application/json', + 'application/zip', + 'application/gzip', + 'application/vnd.openxmlformats-officedocument', + 'application/vnd.ms-excel', + 'application/msword', + 'application/vnd.ms-powerpoint', + 'application/octet-stream', +]; + +const EXT_BY_MIME: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + 'application/pdf': 'pdf', + 'text/plain': 'txt', + 'text/csv': 'csv', + 'application/json': 'json', + 'application/zip': 'zip', +}; + +export interface UploadFileInput { + originalName: string; + mimeType: string; + data: Buffer; +} + +/** + * ファイル共有の業務ロジックを担うService。 + * ローカルFS保存・MIMEチェック・ファイル名サニタイズ・保存名一意化・ + * 権限チェック・ファイル共有通知・アクティビティログ・SSE配信を行う。 + */ +export class FileStorageService { + constructor( + private readonly fileRepository: FileRepository, + private readonly projectMemberRepository: ProjectMemberRepository, + private readonly notificationService: NotificationService, + private readonly activityLogService: ActivityLogService, + private readonly sseHub: SseHub, + private readonly uploadsDir: string + ) {} + + upload( + actorId: number, + projectId: number, + input: UploadFileInput + ): FileAsset { + this.requireMember(projectId, actorId); + if (!input.data || input.data.length === 0) { + throw new ValidationError('ファイルが空です', 'file'); + } + if (!this.isAllowedMime(input.mimeType)) { + throw new ValidationError('許可されていないファイル形式です', 'mimeType'); + } + + const ext = + this.sanitizeExt(input.originalName) ?? + EXT_BY_MIME[input.mimeType] ?? + 'bin'; + const filename = `${crypto.randomUUID()}.${ext}`; + const dir = path.join(this.uploadsDir, String(projectId)); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, filename); + fs.writeFileSync(filePath, input.data); + + const fileAsset = this.fileRepository.create({ + projectId, + uploaderId: actorId, + filename, + originalName: this.sanitizeName(input.originalName) || 'file', + mimeType: input.mimeType, + size: input.data.length, + path: filePath, + }); + + const memberIds = this.projectMemberRepository + .findByProject(projectId) + .map((m) => m.userId) + .filter((uid) => uid !== actorId); + if (memberIds.length > 0) { + this.notificationService.notifyOnEvent({ + type: 'file_shared', + projectId, + title: `ファイル「${fileAsset.originalName}」が共有されました`, + body: null, + projectMemberIds: memberIds, + }); + } + this.activityLogService.logActivity({ + projectId, + actorId, + action: 'file_uploaded', + targetType: 'file', + targetId: fileAsset.id, + }); + this.sseHub.broadcast(projectId, { + type: 'file.uploaded', + data: { projectId }, + }); + return fileAsset; + } + + listFiles(actorId: number, projectId: number, page: number = 1) { + this.requireMember(projectId, actorId); + return this.fileRepository.findFilesByProject(projectId, page); + } + + getFileInfo(actorId: number, fileId: number): FileAsset { + const file = this.fileRepository.findFileById(fileId); + if (!file) throw new NotFoundError('FileAsset', fileId); + this.requireMember(file.projectId, actorId); + return file; + } + + delete(actorId: number, fileId: number): void { + const file = this.fileRepository.findFileById(fileId); + if (!file) throw new NotFoundError('FileAsset', fileId); + this.requireMember(file.projectId, actorId); + const role = this.projectMemberRepository.getRole(file.projectId, actorId); + if (file.uploaderId !== actorId && role !== 'admin') { + throw new ForbiddenError('アップロード者または管理者のみ削除できます'); + } + this.fileRepository.delete(fileId); + try { + fs.unlinkSync(file.path); + } catch { + // FSファイルが無くてもDB論理削除は成功させる + } + } + + private requireMember(projectId: number, actorId: number): void { + if (!this.projectMemberRepository.isMember(projectId, actorId)) { + throw new ForbiddenError('プロジェクトに参加していません'); + } + } + + private isAllowedMime(mimeType: string): boolean { + if (!mimeType) return false; + return ALLOWED_MIME_PREFIXES.some((prefix) => + prefix.endsWith('/') + ? mimeType.startsWith(prefix) + : mimeType === prefix || mimeType.startsWith(prefix) + ); + } + + private sanitizeExt(originalName: string): string | null { + const base = path.basename(originalName); + const match = base.match(/\.([a-zA-Z0-9]+)$/); + return match ? match[1].toLowerCase() : null; + } + + private sanitizeName(originalName: string): string { + return path + .basename(originalName) + .replace(/[^\w.-]+/g, '_') + .slice(0, 200); + } +} diff --git a/tests/e2e/file-sharing.spec.ts b/tests/e2e/file-sharing.spec.ts new file mode 100644 index 0000000..59bca30 --- /dev/null +++ b/tests/e2e/file-sharing.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; + +function unique(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +// 1x1 透明PNG +const PNG_BUFFER = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' +); + +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('file sharing', () => { + test('upload, view in lightbox, and delete a file', async ({ page }) => { + const projectId = await setupOwner(page); + + // アップロード(multipart) + const uploadRes = await page.request.post( + `/api/projects/${projectId}/files`, + { + multipart: { + file: { + name: 'tiny.png', + mimeType: 'image/png', + buffer: PNG_BUFFER, + }, + }, + } + ); + expect(uploadRes.ok()).toBeTruthy(); + const { file } = (await uploadRes.json()) as { file: { id: number } }; + + // ファイル一覧に表示 + await page.goto(`/projects/${projectId}/files`); + await expect(page.getByTestId(`file-item-${file.id}`)).toBeVisible(); + + // 画像をクリック → Lightbox + await page + .getByTestId(`file-item-${file.id}`) + .getByRole('button') + .first() + .click(); + await expect(page.getByTestId('lightbox')).toBeVisible(); + // Lightboxを閉じる(リロードで確実に解除) + await page.reload(); + + // 削除(API) + const delRes = await page.request.delete(`/api/files/${file.id}`); + expect(delRes.ok()).toBeTruthy(); + await page.reload(); + await expect(page.getByTestId(`file-item-${file.id}`)).toHaveCount(0); + }); +}); diff --git a/tests/unit/repositories/FileRepository.test.ts b/tests/unit/repositories/FileRepository.test.ts new file mode 100644 index 0000000..165ebdc --- /dev/null +++ b/tests/unit/repositories/FileRepository.test.ts @@ -0,0 +1,88 @@ +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 { FileRepository } from '@/repositories/FileRepository'; + +describe('FileRepository', () => { + let db: SqliteDatabase; + let repo: FileRepository; + let projectId: number; + let userId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + repo = new FileRepository(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 createFile(name: string) { + return repo.create({ + projectId, + uploaderId: userId, + filename: `${name}.bin`, + originalName: name, + mimeType: 'application/octet-stream', + size: 10, + path: `/tmp/${name}.bin`, + }); + } + + it('creates and finds a file', () => { + const f = createFile('test'); + expect(repo.findFileById(f.id)?.originalName).toBe('test'); + }); + + it('lists files newest first with total', () => { + createFile('a'); + createFile('b'); + const { items, total } = repo.findFilesByProject(projectId); + expect(total).toBe(2); + expect(items[0].originalName).toBe('b'); + }); + + it('excludes soft-deleted files', () => { + const f = createFile('a'); + repo.delete(f.id); + expect(repo.findFileById(f.id)).toBeNull(); + expect(repo.findFilesByProject(projectId).total).toBe(0); + }); + + it('isolates files by project', () => { + createFile('mine'); + const p2 = new ProjectRepository(db).create({ + name: 'P2', + ownerId: userId, + }).id; + repo.create({ + projectId: p2, + uploaderId: userId, + filename: 'x.bin', + originalName: 'theirs', + mimeType: 'application/octet-stream', + size: 1, + path: '/tmp/x.bin', + }); + expect(repo.findFilesByProject(projectId).total).toBe(1); + expect(repo.findFilesByProject(p2).total).toBe(1); + }); + + it('paginates files', () => { + for (let i = 0; i < 5; i++) createFile(`f${i}`); + expect(repo.findFilesByProject(projectId, 1, 2).items).toHaveLength(2); + expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5); + }); +}); diff --git a/tests/unit/services/FileStorageService.test.ts b/tests/unit/services/FileStorageService.test.ts new file mode 100644 index 0000000..e504119 --- /dev/null +++ b/tests/unit/services/FileStorageService.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +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 { FileRepository } from '@/repositories/FileRepository'; +import { NotificationRepository } from '@/repositories/NotificationRepository'; +import { ActivityLogRepository } from '@/repositories/ActivityLogRepository'; +import { NotificationService } from '@/services/NotificationService'; +import { ActivityLogService } from '@/services/ActivityLogService'; +import { FileStorageService } from '@/services/FileStorageService'; +import { SseHub } from '@/lib/sse/hub'; +import { ForbiddenError, ValidationError } from '@/lib/errors'; + +describe('FileStorageService', () => { + let db: SqliteDatabase; + let service: FileStorageService; + let uploadsDir: string; + let projectId: number; + let authorId: number; + let memberId: number; + let outsiderId: number; + + beforeEach(() => { + db = createMigratedTestDb(); + uploadsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uploads-')); + 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'); + service = new FileStorageService( + new FileRepository(db), + members, + new NotificationService(new NotificationRepository(db)), + new ActivityLogService(new ActivityLogRepository(db)), + new SseHub(), + uploadsDir + ); + }); + + afterEach(() => { + db.close(); + fs.rmSync(uploadsDir, { recursive: true, force: true }); + }); + + it('uploads a file: writes to FS, stores metadata, notifies members, logs activity', () => { + const file = service.upload(authorId, projectId, { + originalName: 'photo.png', + mimeType: 'image/png', + data: Buffer.from('fake-png-bytes'), + }); + + expect(file.id).toBeGreaterThan(0); + expect(file.originalName).toBe('photo.png'); + expect(fs.existsSync(file.path)).toBe(true); + // メンバー(memberId)へ file_shared 通知 + expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(1); + expect( + new ActivityLogRepository(db) + .findByProject(projectId) + .items.some((l) => l.action === 'file_uploaded') + ).toBe(true); + }); + + it('rejects a disallowed MIME type', () => { + expect(() => + service.upload(authorId, projectId, { + originalName: 'evil.exe', + mimeType: 'application/x-msdownload', + data: Buffer.from('x'), + }) + ).toThrow(ValidationError); + }); + + it('rejects an empty file', () => { + expect(() => + service.upload(authorId, projectId, { + originalName: 'empty.txt', + mimeType: 'text/plain', + data: Buffer.alloc(0), + }) + ).toThrow(ValidationError); + }); + + it('forbids a non-member from uploading', () => { + expect(() => + service.upload(outsiderId, projectId, { + originalName: 'x.png', + mimeType: 'image/png', + data: Buffer.from('x'), + }) + ).toThrow(ForbiddenError); + }); + + it('sanitizes the saved filename to a unique uuid-based name', () => { + const file = service.upload(authorId, projectId, { + originalName: 'photo.png', + mimeType: 'image/png', + data: Buffer.from('x'), + }); + expect(file.filename).toMatch(/^[0-9a-f-]{36}\.png$/); + }); + + it('getFileInfo requires membership', () => { + const file = service.upload(authorId, projectId, { + originalName: 'a.txt', + mimeType: 'text/plain', + data: Buffer.from('hi'), + }); + expect(service.getFileInfo(authorId, file.id).id).toBe(file.id); + expect(() => service.getFileInfo(outsiderId, file.id)).toThrow( + ForbiddenError + ); + }); + + it('delete: uploader can delete; non-uploader non-admin cannot', () => { + const file = service.upload(memberId, projectId, { + originalName: 'm.txt', + mimeType: 'text/plain', + data: Buffer.from('hi'), + }); + expect(() => service.delete(outsiderId, file.id)).toThrow(ForbiddenError); + // 作者(member)は削除可 + service.delete(memberId, file.id); + expect(fs.existsSync(file.path)).toBe(false); + }); + + it('admin can delete another member file', () => { + const file = service.upload(memberId, projectId, { + originalName: 'm.txt', + mimeType: 'text/plain', + data: Buffer.from('hi'), + }); + service.delete(authorId, file.id); // authorId is admin + expect(() => service.getFileInfo(memberId, file.id)).toThrow(); + }); +});