Merge branch 'feature/m10-file-sharing' - M10 file sharing & lightbox

This commit is contained in:
Ken Yasue
2026-06-25 02:02:42 +02:00
13 changed files with 945 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -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<ReturnType<typeof projectService.getProject>>;
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 (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="files" />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<Uploader projectId={project.id} />
<FileList files={items} canDelete={canDelete} />
</main>
</div>
);
}

View File

@ -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<FileAsset | null>(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 <p className="text-sm text-gray-400"></p>;
}
return (
<>
<ul
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
data-testid="file-list"
>
{files.map((file) => {
const isImage = file.mimeType.startsWith('image/');
const isPdf = file.mimeType === 'application/pdf';
const url = `/api/files/${file.id}/download`;
return (
<li
key={file.id}
className="rounded-lg border bg-white p-3 shadow-sm"
data-testid={`file-item-${file.id}`}
>
{isImage ? (
<button
type="button"
onClick={() => setLightbox(file)}
className="block w-full"
>
<img
src={url}
alt={file.originalName}
className="h-24 w-full rounded object-cover"
/>
</button>
) : isPdf ? (
<a
href={url}
target="_blank"
rel="noreferrer"
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
>
PDF
</a>
) : (
<a
href={url}
className="flex h-24 items-center justify-center rounded bg-gray-100 text-sm text-blue-600 hover:underline"
>
</a>
)}
<p
className="mt-2 truncate text-xs text-gray-700"
title={file.originalName}
>
{file.originalName}
</p>
<p className="text-xs text-gray-400">{file.size} bytes</p>
{canDelete && (
<button
type="button"
onClick={() => onDelete(file.id)}
className="mt-1 text-xs text-red-600 hover:underline"
>
</button>
)}
</li>
);
})}
</ul>
{lightbox && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setLightbox(null)}
data-testid="lightbox"
>
<img
src={`/api/files/${lightbox.id}/download`}
alt={lightbox.originalName}
className="max-h-full max-w-full rounded"
/>
</div>
)}
</>
);
}

View File

@ -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<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<label className="block text-sm font-medium">
</label>
<input
ref={inputRef}
type="file"
onChange={onFile}
disabled={loading}
className="mt-2 block text-sm"
data-testid="file-input"
/>
{loading && (
<p className="mt-1 text-sm text-gray-500">...</p>
)}
{error && (
<p className="mt-1 text-sm text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -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',

View File

@ -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());
}

View File

@ -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<T> {
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<FileAsset> {
const offset = (page - 1) * pageSize;
const items = this.db.query<FileAssetRow>(
`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<FileAssetRow>(
'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;
}
}

View File

@ -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<string, string> = {
'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);
}
}

View File

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

View File

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

View File

@ -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();
});
});