feat(m2): DB foundation tests + multi-statement migration fix
- Add SqliteDatabase.exec() using better-sqlite3 exec() so the migrator can run multi-statement SQL files (prepare() rejected 001_initial.sql) - Migrator now uses exec() for migration file content - Fix tests/helpers/db.ts: replace CJS require() with ESM import - Add unit tests for SqliteDatabase (query/get/execute/transaction/exec, WAL & foreign_keys pragmas) and Migrator (ordering, idempotency, rollback, real 001_initial.sql applies) - Restore .gitignore steering exclusion; remove stale steering files
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -47,6 +47,9 @@ Thumbs.db
|
|||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
|
# Steering files (task management - temporary)
|
||||||
|
.steering/*
|
||||||
|
!.steering/.gitkeep
|
||||||
|
|
||||||
# Lock files (keep package-lock.json for consistency)
|
# Lock files (keep package-lock.json for consistency)
|
||||||
# yarn.lock
|
# yarn.lock
|
||||||
|
|||||||
@ -1,189 +0,0 @@
|
|||||||
# Design Document
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Layered architecture (UI → Service → Repository → Data) per `docs/architecture.md`. Next.js 15 App Router with Node.js Runtime only (no Edge Runtime). SQLite via better-sqlite3 accessed exclusively through a custom SQL wrapper. No Prisma.
|
|
||||||
|
|
||||||
```
|
|
||||||
app/ (UI: Route Handlers / Server Components / SSE)
|
|
||||||
↓
|
|
||||||
services/ (business logic, permission checks, transactions)
|
|
||||||
↓
|
|
||||||
repositories/ (SQL, parameter binding, logical-delete filter)
|
|
||||||
↓
|
|
||||||
lib/db/ (SQLite connection, SQL wrapper, Migration)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Design
|
|
||||||
|
|
||||||
### 1. SQL Wrapper: `lib/db/sqlite.ts`
|
|
||||||
|
|
||||||
**Responsibilities**:
|
|
||||||
- SQLite connection management (singleton getDb(), WAL, foreign_keys ON)
|
|
||||||
- query<T> (multi-row), get<T> (single-row), execute (insert/update/delete), transaction<T>, close
|
|
||||||
- Error handling
|
|
||||||
|
|
||||||
**Implementation highlights**:
|
|
||||||
- dbPath = process.env.SQLITE_PATH ?? "./data/app.db"
|
|
||||||
- SqlParams = Record<string, unknown> | unknown[]
|
|
||||||
- All repositories use this wrapper; direct better-sqlite3 access forbidden
|
|
||||||
|
|
||||||
### 2. Migrator: `lib/db/migrator.ts`
|
|
||||||
|
|
||||||
**Responsibilities**:
|
|
||||||
- Create schema_migrations table
|
|
||||||
- Read .sql files from migrations dir in filename order
|
|
||||||
- Skip already-applied files (tracked in schema_migrations)
|
|
||||||
- Execute each file in a transaction; rollback on failure
|
|
||||||
|
|
||||||
### 3. Initial Schema: `lib/db/migrations/001_initial.sql`
|
|
||||||
|
|
||||||
16 tables: users, projects, project_members, board_threads, board_comments, chat_messages, todo_columns, todo_items, file_assets, project_notes, milestones, calendar_events, meetings, meeting_members, notifications, activity_logs + indexes.
|
|
||||||
|
|
||||||
### 4. Entity Types: `lib/types/`
|
|
||||||
|
|
||||||
PascalCase type files for each entity + union types (UserRole, UserStatus, ProjectStatus, ProjectMemberRole, etc.).
|
|
||||||
|
|
||||||
### 5. UserRepository
|
|
||||||
|
|
||||||
findById, findByEmail, create (with passwordHash), update (name/avatarUrl/role/status).
|
|
||||||
|
|
||||||
### 6. AuthService
|
|
||||||
|
|
||||||
register (hash password, create user), login (verify hash, issue session), logout, getCurrentUser, updateProfile. Uses bcrypt.
|
|
||||||
|
|
||||||
### 7. Session: `lib/auth/session.ts`, `lib/auth/getCurrentUser.ts`
|
|
||||||
|
|
||||||
Cookie-based session. getCurrentUser resolves the user from the request cookie.
|
|
||||||
|
|
||||||
### 8. ProjectRepository / ProjectMemberRepository
|
|
||||||
|
|
||||||
Project: findById, findByOwner, findByUser (via join), create, update, delete.
|
|
||||||
ProjectMember: findByProject, findByUser, add, remove, isMember, getRole.
|
|
||||||
|
|
||||||
### 9. ProjectService
|
|
||||||
|
|
||||||
createProject (owner becomes admin member), updateProject, addMember (permission check + notification hook later), removeMember, archiveProject, getDashboard skeleton.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Login
|
|
||||||
1. POST /api/auth/login → AuthService.login(email, password)
|
|
||||||
2. AuthService → UserRepository.findByEmail → verify bcrypt hash
|
|
||||||
3. Issue session cookie → return user
|
|
||||||
|
|
||||||
### Project creation
|
|
||||||
1. POST /api/projects → ProjectService.createProject(actorId, input)
|
|
||||||
2. ProjectService → permission (any authenticated user) → ProjectRepository.create
|
|
||||||
3. ProjectMemberRepository.add(projectId, ownerId, 'admin')
|
|
||||||
4. Return project
|
|
||||||
|
|
||||||
## Error Handling Strategy
|
|
||||||
|
|
||||||
### Custom Error Classes
|
|
||||||
- ValidationError (400) - field, value
|
|
||||||
- ForbiddenError (403)
|
|
||||||
- NotFoundError (404) - resource, id
|
|
||||||
- (409 for unique constraint - handled via SQLite error code)
|
|
||||||
|
|
||||||
### Error Handling Patterns
|
|
||||||
Route Handlers catch errors and map to HTTP status. Expected errors use custom classes; unexpected errors propagate + log.
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
### Unit Tests (Vitest)
|
|
||||||
- lib/db/sqlite.test.ts (query/get/execute/transaction, WAL, foreign keys)
|
|
||||||
- lib/db/migrator.test.ts (order, skip applied, rollback)
|
|
||||||
- repositories/UserRepository.test.ts
|
|
||||||
- services/AuthService.test.ts
|
|
||||||
- repositories/ProjectRepository.test.ts
|
|
||||||
- repositories/ProjectMemberRepository.test.ts
|
|
||||||
- services/ProjectService.test.ts
|
|
||||||
|
|
||||||
Tests use a temp SQLite file (in-memory or tmpdir) to avoid touching real data/.
|
|
||||||
|
|
||||||
### Integration Tests (Vitest)
|
|
||||||
- project-member-permission.test.ts (non-member cannot access project data)
|
|
||||||
|
|
||||||
### E2E Tests (Playwright)
|
|
||||||
- auth.spec.ts (login, logout, protected screen)
|
|
||||||
- project-management.spec.ts (create, edit, member add/remove, archive)
|
|
||||||
|
|
||||||
## Dependent Libraries
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"next": "15",
|
|
||||||
"react": "19",
|
|
||||||
"react-dom": "19",
|
|
||||||
"better-sqlite3": "^11",
|
|
||||||
"bcrypt": "^5",
|
|
||||||
"react-markdown": "^9",
|
|
||||||
"remark-gfm": "^4",
|
|
||||||
"rehype-sanitize": "^6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"tailwindcss": "^3",
|
|
||||||
"@types/better-sqlite3": "^7",
|
|
||||||
"@types/bcrypt": "^5",
|
|
||||||
"@playwright/test": "^1",
|
|
||||||
"tsx": "^4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
app/
|
|
||||||
api/auth/{register,login,logout,me}/route.ts
|
|
||||||
api/users/me/route.ts
|
|
||||||
api/projects/route.ts
|
|
||||||
api/projects/[projectId]/route.ts
|
|
||||||
api/projects/[projectId]/members/route.ts
|
|
||||||
api/projects/[projectId]/members/[userId]/route.ts
|
|
||||||
api/admin/migrations/route.ts
|
|
||||||
login/page.tsx
|
|
||||||
profile/page.tsx
|
|
||||||
dashboard/page.tsx
|
|
||||||
projects/[projectId]/{page,members,settings}.tsx
|
|
||||||
layout.tsx, globals.css
|
|
||||||
lib/
|
|
||||||
db/{sqlite.ts, migrator.ts, run-migrations.ts, migrations/001_initial.sql}
|
|
||||||
auth/{session.ts, getCurrentUser.ts}
|
|
||||||
types/*.ts
|
|
||||||
validators/{userValidator.ts, projectValidator.ts}
|
|
||||||
repositories/{UserRepository,ProjectRepository,ProjectMemberRepository}.ts
|
|
||||||
services/{AuthService,ProjectService}.ts
|
|
||||||
components/layout/{Header,Sidebar,ProjectNav}.tsx
|
|
||||||
tests/unit/..., tests/integration/..., tests/e2e/...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. M1 (branch feature/m1-setup): Next.js setup, deps, config, dir structure, remove boilerplate → merge to main
|
|
||||||
2. M2 (branch feature/m2-db-foundation): SQL wrapper, migrator, schema, types, migration API, unit tests → merge to main
|
|
||||||
3. M3 (branch feature/m3-auth-user): UserRepository, AuthService, session, validators, auth API, login/profile pages, unit + e2e → merge to main
|
|
||||||
4. M4 (branch feature/m4-project-member): Project/Member repos, ProjectService, project API, dashboard/members/settings pages, unit + integration + e2e → merge to main
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- Passwords hashed with bcrypt (never plaintext)
|
|
||||||
- All SQL parameter-bound (no string concatenation)
|
|
||||||
- Auth required on all protected routes (except /api/auth/register, /api/auth/login)
|
|
||||||
- Project access requires isMember check
|
|
||||||
- Admin features require role='system_admin'
|
|
||||||
- .env not committed; secrets via environment
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- SQLite WAL mode for read/write concurrency
|
|
||||||
- Pagination on list endpoints
|
|
||||||
- Singleton DB connection
|
|
||||||
|
|
||||||
## Future Extensibility
|
|
||||||
|
|
||||||
- Repository pattern allows adding tables without changing wrapper
|
|
||||||
- SseEvent type extensible (M8)
|
|
||||||
- NotificationService/ActivityLogService hooks prepared for M5 (ProjectService.addMember will call notification in M5)
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
# Requirements
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Implement milestones M1-M4 of the Simple Groupware project: project foundation setup (Next.js 15), DB foundation (SQL wrapper + Migration), authentication & user management, and project & member management. Each milestone is developed on a separate branch and merged to main. Unit tests and E2E tests are mandatory.
|
|
||||||
|
|
||||||
## Background
|
|
||||||
|
|
||||||
The repository is currently a spec-driven-development boilerplate (vitest/eslint/prettier/husky configured, but no Next.js, no react, no SQLite). It must be transformed into the foundation of the Simple Groupware: a Next.js 15 + TypeScript + SQLite(better-sqlite3) application following the layered architecture (UI → Service → Repository → Data) defined in `docs/architecture.md`. M1-M4 establish the base that all subsequent milestones (M5+) depend on.
|
|
||||||
|
|
||||||
## Features to Implement
|
|
||||||
|
|
||||||
### 1. M1: Project Foundation Setup
|
|
||||||
- Next.js 15 (App Router, TypeScript) project configuration
|
|
||||||
- Tailwind CSS setup
|
|
||||||
- tsconfig with `@/*` path alias, strict mode
|
|
||||||
- next.config, .env.example
|
|
||||||
- Directory structure: app/, lib/, repositories/, services/, components/, tests/, data/, backups/
|
|
||||||
- ESLint (with Next.js plugin), Prettier, Husky, lint-staged, Vitest, Playwright setup
|
|
||||||
- CI config (GitHub Actions)
|
|
||||||
- package.json scripts: lint, format, typecheck, test, test:e2e, migrate, dev, build
|
|
||||||
- Dependencies: next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize, tailwindcss, @types/better-sqlite3
|
|
||||||
- Remove boilerplate src/example.ts and src/example.test.ts
|
|
||||||
|
|
||||||
### 2. M2: DB Foundation (SQL Wrapper + Migration)
|
|
||||||
- `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON)
|
|
||||||
- `lib/db/migrator.ts`: Migrator class (filename-order execution, skip applied, 1-file-1-transaction, rollback on failure)
|
|
||||||
- `lib/db/migrations/001_initial.sql`: all 16 tables + indexes
|
|
||||||
- `lib/types/`: all Entity type definitions + enums
|
|
||||||
- `lib/db/run-migrations.ts`: migration runner script
|
|
||||||
- API: `GET /api/admin/migrations` (admin-only migration status)
|
|
||||||
- Unit tests: sqlite.test.ts, migrator.test.ts
|
|
||||||
|
|
||||||
### 3. M3: Authentication & User Management
|
|
||||||
- UserRepository (findById/findByEmail/create/update)
|
|
||||||
- AuthService (register/login/logout/getCurrentUser/updateProfile) with bcrypt password hashing
|
|
||||||
- Role management (system_admin/project_admin/member/guest), account enable/disable
|
|
||||||
- lib/auth/ (session.ts, getCurrentUser.ts)
|
|
||||||
- lib/validators/userValidator.ts
|
|
||||||
- API: register, login, logout, me, users/me
|
|
||||||
- Login page, profile page, root layout
|
|
||||||
- Auth middleware (redirect unauthenticated to login)
|
|
||||||
- Unit tests: UserRepository, AuthService; E2E test: auth.spec.ts
|
|
||||||
|
|
||||||
### 4. M4: Project & Member Management
|
|
||||||
- ProjectRepository, ProjectMemberRepository
|
|
||||||
- ProjectService (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard) with permission checks
|
|
||||||
- projectValidator.ts
|
|
||||||
- API: projects CRUD, members CRUD
|
|
||||||
- Dashboard (project list), project overview, members page, settings page
|
|
||||||
- Layout components (Header, Sidebar, ProjectNav)
|
|
||||||
- Unit tests: ProjectRepository, ProjectMemberRepository, ProjectService; Integration test: project-member-permission; E2E test: project-management.spec.ts
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
### M1
|
|
||||||
- [ ] `npm run dev` starts the Next.js dev server
|
|
||||||
- [ ] `npm run lint`, `npm run typecheck`, `npm run build` succeed
|
|
||||||
- [ ] Directory structure matches repository-structure.md
|
|
||||||
|
|
||||||
### M2
|
|
||||||
- [ ] `npm run migrate` creates the initial schema
|
|
||||||
- [ ] Migrations run in filename order and skip applied ones
|
|
||||||
- [ ] Failed migration rolls back
|
|
||||||
- [ ] Unit tests pass
|
|
||||||
|
|
||||||
### M3
|
|
||||||
- [ ] User registration, login, logout work
|
|
||||||
- [ ] Password is hashed with bcrypt
|
|
||||||
- [ ] Profile & avatar editable
|
|
||||||
- [ ] Inactive accounts cannot log in
|
|
||||||
- [ ] Unauthenticated access to protected screens is blocked
|
|
||||||
- [ ] Unit & E2E tests pass
|
|
||||||
|
|
||||||
### M4
|
|
||||||
- [ ] Project create/edit/delete/archive work
|
|
||||||
- [ ] Member add/remove/role work
|
|
||||||
- [ ] Non-members get 403
|
|
||||||
- [ ] Unit, Integration & E2E tests pass
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
- All Unit tests pass (`npm test`)
|
|
||||||
- All E2E tests pass (`npm run test:e2e`)
|
|
||||||
- Lint and typecheck clean
|
|
||||||
- Each milestone merged to main on its own branch
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
The following will not be implemented in this phase:
|
|
||||||
- M5-M15 (notifications, board, chat, SSE, todo, files, calendar, milestones, meetings, search, dashboard completion, backup)
|
|
||||||
- E2E tests for features beyond M3/M4 scope (board, chat, etc.)
|
|
||||||
- Full project dashboard content (only skeleton in M4; full content in M13)
|
|
||||||
|
|
||||||
## Reference Documents
|
|
||||||
|
|
||||||
- `docs/product-requirements.md` - Product Requirements Document
|
|
||||||
- `docs/functional-design.md` - Functional design document
|
|
||||||
- `docs/architecture.md` - Architecture design document
|
|
||||||
- `docs/repository-structure.md` - Repository structure document
|
|
||||||
- `docs/development-guidelines.md` - Development guidelines
|
|
||||||
- `docs/milestones.md` - Milestone & task definitions
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
# Task List
|
|
||||||
|
|
||||||
## 🚨 Principle of Fully Completing Tasks
|
|
||||||
|
|
||||||
**Keep working until all tasks in this file are complete**
|
|
||||||
|
|
||||||
### Mandatory Rules
|
|
||||||
- **Make every task `[x]`**
|
|
||||||
- "Planned as a separate task due to time constraints" is forbidden
|
|
||||||
- "Postponed because the implementation is too complex" is forbidden
|
|
||||||
- Do not finish work while leaving incomplete tasks (`[ ]`)
|
|
||||||
|
|
||||||
### The Only Case Where Skipping a Task Is Permitted
|
|
||||||
Skipping is possible only when one of the following technical reasons applies:
|
|
||||||
- A change in the implementation approach made the feature itself unnecessary
|
|
||||||
- An architecture change replaced it with a different implementation method
|
|
||||||
- A change in dependencies made the task impossible to execute
|
|
||||||
|
|
||||||
When skipping, always state the reason clearly:
|
|
||||||
```markdown
|
|
||||||
- [x] ~~task name~~ (unnecessary due to a change in approach: specific technical reason)
|
|
||||||
```
|
|
||||||
|
|
||||||
### If a Task Is Too Large
|
|
||||||
- Split the task into smaller subtasks
|
|
||||||
- Add the split subtasks to this file
|
|
||||||
- Complete the subtasks one by one
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: M1 - Project Foundation Setup (branch: feature/m1-setup)
|
|
||||||
|
|
||||||
- [x] Create branch `feature/m1-setup` from main
|
|
||||||
- [x] Update package.json: name, scripts (dev/build/lint/format/typecheck/test/test:e2e/migrate), dependencies (next, react, react-dom, better-sqlite3, bcrypt, react-markdown, remark-gfm, rehype-sanitize), devDependencies (tailwindcss, @types/better-sqlite3, @types/bcrypt, @playwright/test, tsx, eslint-config-next)
|
|
||||||
- [x] Run `npm install` and confirm dependencies install successfully
|
|
||||||
- [x] Configure Next.js: create next.config.mjs (Node.js runtime), update tsconfig.json for Next.js (@/* path alias, jsx preserve, next plugin types), create app/globals.css (Tailwind directives), create tailwind.config.ts, postcss.config.mjs
|
|
||||||
- [x] Create Next.js app structure: app/layout.tsx (root layout), app/page.tsx (home redirect), app/globals.css
|
|
||||||
- [x] Create directory structure: lib/, repositories/, services/, components/, tests/unit, tests/integration, tests/e2e, data/.gitkeep, backups/.gitkeep
|
|
||||||
- [x] Remove boilerplate: delete src/example.ts, src/example.test.ts
|
|
||||||
- [x] Update vitest.config.ts (include tests/**, exclude .next), create playwright.config.ts
|
|
||||||
- [x] Update eslint.config.js (add Next.js plugin/ignores for .next), update .prettierrc if needed
|
|
||||||
- [x] Create .env.example (SQLITE_PATH, SESSION_SECRET)
|
|
||||||
- [x] Create CI config .github/workflows/ci.yml (lint, typecheck, test, build)
|
|
||||||
- [x] Commit M1, merge feature/m1-setup to main, push
|
|
||||||
|
|
||||||
## Phase 2: M2 - DB Foundation (branch: feature/m2-db-foundation)
|
|
||||||
|
|
||||||
- [ ] Create branch `feature/m2-db-foundation` from main
|
|
||||||
- [ ] Implement `lib/db/sqlite.ts`: SqliteDatabase class (query/get/execute/transaction/close) + getDb() singleton (WAL, foreign_keys ON)
|
|
||||||
- [ ] Implement `lib/db/migrator.ts`: Migrator class (schema_migrations table, filename-order, skip applied, 1-file-1-tx, rollback)
|
|
||||||
- [ ] Create `lib/db/migrations/001_initial.sql`: all 16 tables + indexes
|
|
||||||
- [ ] Create `lib/types/`: all Entity types + enums (User, Project, ProjectMember, BoardThread, BoardComment, ChatMessage, TodoColumn, TodoItem, FileAsset, ProjectNote, Milestone, CalendarEvent, Meeting, MeetingMember, Notification, ActivityLog, SchemaMigration + union types)
|
|
||||||
- [ ] Create `lib/db/run-migrations.ts`: migration runner script
|
|
||||||
- [ ] Implement API `GET /api/admin/migrations` (migration status, admin-only)
|
|
||||||
- [ ] Create test helper `tests/helpers/db.ts` (temp SQLite DB factory)
|
|
||||||
- [ ] Write Unit test `tests/unit/lib/db/sqlite.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/lib/db/migrator.test.ts`
|
|
||||||
- [ ] Run `npm run migrate` to verify schema creation
|
|
||||||
- [ ] Commit M2, merge feature/m2-db-foundation to main, push
|
|
||||||
|
|
||||||
## Phase 3: M3 - Auth & User Management (branch: feature/m3-auth-user)
|
|
||||||
|
|
||||||
- [ ] Create branch `feature/m3-auth-user` from main
|
|
||||||
- [ ] Create custom error classes `lib/errors.ts` (ValidationError, ForbiddenError, NotFoundError)
|
|
||||||
- [ ] Implement `lib/auth/session.ts` (session cookie read/write) and `lib/auth/getCurrentUser.ts`
|
|
||||||
- [ ] Implement `lib/validators/userValidator.ts`
|
|
||||||
- [ ] Implement `repositories/UserRepository.ts`
|
|
||||||
- [ ] Implement `services/AuthService.ts` (register/login/logout/getCurrentUser/updateProfile, bcrypt)
|
|
||||||
- [ ] Implement API routes: register, login, logout, me, users/me
|
|
||||||
- [ ] Implement auth middleware/guard (protected routes redirect to login)
|
|
||||||
- [ ] Implement `app/login/page.tsx`, `app/profile/page.tsx`, update `app/layout.tsx`
|
|
||||||
- [ ] Write Unit test `tests/unit/repositories/UserRepository.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/services/AuthService.test.ts`
|
|
||||||
- [ ] Write E2E test `tests/e2e/auth.spec.ts`
|
|
||||||
- [ ] Commit M3, merge feature/m3-auth-user to main, push
|
|
||||||
|
|
||||||
## Phase 4: M4 - Project & Member Management (branch: feature/m4-project-member)
|
|
||||||
|
|
||||||
- [ ] Create branch `feature/m4-project-member` from main
|
|
||||||
- [ ] Implement `lib/validators/projectValidator.ts`
|
|
||||||
- [ ] Implement `repositories/ProjectRepository.ts`
|
|
||||||
- [ ] Implement `repositories/ProjectMemberRepository.ts`
|
|
||||||
- [ ] Implement `services/ProjectService.ts` (createProject/updateProject/addMember/removeMember/archiveProject/getDashboard skeleton, permission checks)
|
|
||||||
- [ ] Implement API routes: projects (list/create), projects/[projectId] (detail/edit/delete), members (list/add), members/[userId] (remove)
|
|
||||||
- [ ] Implement `app/dashboard/page.tsx` (project list skeleton)
|
|
||||||
- [ ] Implement `app/projects/[projectId]/page.tsx` (overview skeleton)
|
|
||||||
- [ ] Implement `app/projects/[projectId]/members/page.tsx`
|
|
||||||
- [ ] Implement `app/projects/[projectId]/settings/page.tsx`
|
|
||||||
- [ ] Implement layout components: components/layout/Header.tsx, Sidebar.tsx, ProjectNav.tsx
|
|
||||||
- [ ] Write Unit test `tests/unit/repositories/ProjectRepository.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/repositories/ProjectMemberRepository.test.ts`
|
|
||||||
- [ ] Write Unit test `tests/unit/services/ProjectService.test.ts`
|
|
||||||
- [ ] Write Integration test `tests/integration/project-member-permission.test.ts`
|
|
||||||
- [ ] Write E2E test `tests/e2e/project-management.spec.ts`
|
|
||||||
- [ ] Commit M4, merge feature/m4-project-member to main, push
|
|
||||||
|
|
||||||
## Phase 5: Quality Check and Fixes
|
|
||||||
|
|
||||||
- [ ] Confirm that all tests pass
|
|
||||||
- [ ] `npm test`
|
|
||||||
- [ ] Confirm that there are no lint errors
|
|
||||||
- [ ] `npm run lint`
|
|
||||||
- [ ] Confirm that there are no type errors
|
|
||||||
- [ ] `npm run typecheck`
|
|
||||||
- [ ] Confirm that the build succeeds
|
|
||||||
- [ ] `npm run build`
|
|
||||||
|
|
||||||
## Phase 6: Documentation Updates
|
|
||||||
|
|
||||||
- [ ] Update README.md (setup instructions, scripts)
|
|
||||||
- [ ] Post-implementation retrospective (record at the bottom of this file)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Post-Implementation Retrospective
|
|
||||||
|
|
||||||
### Implementation Completion Date
|
|
||||||
{YYYY-MM-DD}
|
|
||||||
|
|
||||||
### Differences Between Plan and Actual
|
|
||||||
|
|
||||||
**Points that differed from the plan**:
|
|
||||||
- {Technical changes not anticipated at planning time}
|
|
||||||
- {Changes in the implementation approach and the reasons}
|
|
||||||
|
|
||||||
**Tasks that became newly necessary**:
|
|
||||||
- {Tasks added during implementation}
|
|
||||||
- {Why the addition was necessary}
|
|
||||||
|
|
||||||
**Tasks skipped for technical reasons** (only when applicable):
|
|
||||||
- {Task name}
|
|
||||||
- Reason for skipping: {specific technical reason}
|
|
||||||
- Alternative implementation: {what it was replaced with}
|
|
||||||
|
|
||||||
**⚠️ Note**: Do not list tasks skipped for reasons such as "time constraints" or "difficulty" here. Completing all tasks is the principle.
|
|
||||||
|
|
||||||
### Lessons Learned
|
|
||||||
|
|
||||||
**Technical insights**:
|
|
||||||
- {Technical knowledge gained through implementation}
|
|
||||||
- {New technologies or patterns used}
|
|
||||||
|
|
||||||
**Process improvements**:
|
|
||||||
- {What went well in task management}
|
|
||||||
- {How the steering files were leveraged}
|
|
||||||
|
|
||||||
### Improvement Suggestions for Next Time
|
|
||||||
- {Things to watch out for in the next feature addition}
|
|
||||||
- {More efficient implementation methods}
|
|
||||||
- {Improvements to task planning}
|
|
||||||
@ -37,7 +37,7 @@ export class Migrator {
|
|||||||
const sql = fs.readFileSync(fullPath, 'utf-8');
|
const sql = fs.readFileSync(fullPath, 'utf-8');
|
||||||
|
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
this.db.execute(sql);
|
this.db.exec(sql);
|
||||||
this.db.execute(
|
this.db.execute(
|
||||||
`INSERT INTO schema_migrations (filename, applied_at) VALUES (@filename, @appliedAt)`,
|
`INSERT INTO schema_migrations (filename, applied_at) VALUES (@filename, @appliedAt)`,
|
||||||
{ filename: file, appliedAt: new Date().toISOString() }
|
{ filename: file, appliedAt: new Date().toISOString() }
|
||||||
|
|||||||
@ -37,7 +37,7 @@ export class SqliteDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* INSERT / UPDATE / DELETEを実行する
|
* INSERT / UPDATE / DELETEを実行する(単一プリペアドステートメント)
|
||||||
*/
|
*/
|
||||||
execute(sql: string, params?: SqlParams): ExecuteResult {
|
execute(sql: string, params?: SqlParams): ExecuteResult {
|
||||||
const result = this.db.prepare(sql).run(params ?? {});
|
const result = this.db.prepare(sql).run(params ?? {});
|
||||||
@ -48,6 +48,14 @@ export class SqliteDatabase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 複数ステートメントを含むSQLを一括実行する(Migration用・パラメータバインド不可)。
|
||||||
|
* better-sqlite3の exec() を利用し、セミコロン区切りの複数文・コメントを処理する。
|
||||||
|
*/
|
||||||
|
exec(sql: string): void {
|
||||||
|
this.db.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* トランザクション内でコールバックを実行する
|
* トランザクション内でコールバックを実行する
|
||||||
*/
|
*/
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@ -133,7 +133,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
@ -145,7 +144,6 @@
|
|||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
@ -1643,7 +1641,6 @@
|
|||||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.61.1"
|
"playwright": "1.61.1"
|
||||||
},
|
},
|
||||||
@ -2142,7 +2139,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@ -2208,7 +2204,6 @@
|
|||||||
"integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
|
"integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.62.0",
|
"@typescript-eslint/scope-manager": "8.62.0",
|
||||||
"@typescript-eslint/types": "8.62.0",
|
"@typescript-eslint/types": "8.62.0",
|
||||||
@ -2913,7 +2908,6 @@
|
|||||||
"integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==",
|
"integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "2.1.9",
|
"@vitest/utils": "2.1.9",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
@ -2957,7 +2951,6 @@
|
|||||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -3524,7 +3517,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.38",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"caniuse-lite": "^1.0.30001799",
|
"caniuse-lite": "^1.0.30001799",
|
||||||
@ -4511,7 +4503,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@ -6497,7 +6488,6 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@ -8637,7 +8627,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@ -8948,7 +8937,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -8958,7 +8946,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@ -10540,7 +10527,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -10672,7 +10658,6 @@
|
|||||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.28.0"
|
"esbuild": "~0.28.0"
|
||||||
},
|
},
|
||||||
@ -10810,7 +10795,6 @@
|
|||||||
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
|
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -11603,7 +11587,6 @@
|
|||||||
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "2.1.9",
|
"@vitest/expect": "2.1.9",
|
||||||
"@vitest/mocker": "2.1.9",
|
"@vitest/mocker": "2.1.9",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import os from 'node:os';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { SqliteDatabase } from '@/lib/db/sqlite';
|
import { SqliteDatabase } from '@/lib/db/sqlite';
|
||||||
|
import { Migrator } from '@/lib/db/migrator';
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
|
|
||||||
@ -38,8 +39,6 @@ export function createTestDb(): SqliteDatabase {
|
|||||||
export function createMigratedTestDb(): SqliteDatabase {
|
export function createMigratedTestDb(): SqliteDatabase {
|
||||||
const db = createTestDb();
|
const db = createTestDb();
|
||||||
const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations');
|
const migrationsDir = path.join(process.cwd(), 'lib', 'db', 'migrations');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { Migrator } = require('@/lib/db/migrator');
|
|
||||||
const migrator = new Migrator(db, migrationsDir);
|
const migrator = new Migrator(db, migrationsDir);
|
||||||
migrator.migrate();
|
migrator.migrate();
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
185
tests/unit/lib/db/migrator.test.ts
Normal file
185
tests/unit/lib/db/migrator.test.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createTestDb } from '@/tests/helpers/db';
|
||||||
|
import { Migrator } from '@/lib/db/migrator';
|
||||||
|
import { SqliteDatabase } from '@/lib/db/sqlite';
|
||||||
|
|
||||||
|
function createTempMigrationsDir(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'migrations-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMigration(dir: string, filename: string, sql: string): void {
|
||||||
|
fs.writeFileSync(path.join(dir, filename), sql, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Migrator', () => {
|
||||||
|
let db: SqliteDatabase;
|
||||||
|
let migrationsDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db = createTestDb();
|
||||||
|
migrationsDir = createTempMigrationsDir();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(migrationsDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the schema_migrations table', () => {
|
||||||
|
writeMigration(
|
||||||
|
migrationsDir,
|
||||||
|
'001_init.sql',
|
||||||
|
'CREATE TABLE sample (id INTEGER);'
|
||||||
|
);
|
||||||
|
const migrator = new Migrator(db, migrationsDir);
|
||||||
|
|
||||||
|
migrator.migrate();
|
||||||
|
|
||||||
|
const row = db.get<{ name: string }>(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'"
|
||||||
|
);
|
||||||
|
expect(row?.name).toBe('schema_migrations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies migration files in filename order', () => {
|
||||||
|
writeMigration(
|
||||||
|
migrationsDir,
|
||||||
|
'002_second.sql',
|
||||||
|
'CREATE TABLE second (id INTEGER);'
|
||||||
|
);
|
||||||
|
writeMigration(
|
||||||
|
migrationsDir,
|
||||||
|
'001_first.sql',
|
||||||
|
'CREATE TABLE first (id INTEGER);'
|
||||||
|
);
|
||||||
|
const migrator = new Migrator(db, migrationsDir);
|
||||||
|
|
||||||
|
migrator.migrate();
|
||||||
|
|
||||||
|
const applied = migrator.getAppliedMigrations();
|
||||||
|
expect(applied.map((m) => m.filename)).toEqual([
|
||||||
|
'001_first.sql',
|
||||||
|
'002_second.sql',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
db.get<{ name: string }>(
|
||||||
|
"SELECT name FROM sqlite_master WHERE name='first'"
|
||||||
|
)
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
db.get<{ name: string }>(
|
||||||
|
"SELECT name FROM sqlite_master WHERE name='second'"
|
||||||
|
)
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips already-applied migrations on subsequent runs', () => {
|
||||||
|
writeMigration(
|
||||||
|
migrationsDir,
|
||||||
|
'001_init.sql',
|
||||||
|
'CREATE TABLE sample (id INTEGER);'
|
||||||
|
);
|
||||||
|
const migrator = new Migrator(db, migrationsDir);
|
||||||
|
|
||||||
|
migrator.migrate();
|
||||||
|
expect(migrator.getAppliedMigrations()).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(() => migrator.migrate()).not.toThrow();
|
||||||
|
expect(migrator.getAppliedMigrations()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back and does not record a migration when its SQL fails', () => {
|
||||||
|
writeMigration(
|
||||||
|
migrationsDir,
|
||||||
|
'001_bad.sql',
|
||||||
|
'CREATE TABLE will_exist (id INTEGER); NOT A VALID STATEMENT;'
|
||||||
|
);
|
||||||
|
const migrator = new Migrator(db, migrationsDir);
|
||||||
|
|
||||||
|
expect(() => migrator.migrate()).toThrow();
|
||||||
|
|
||||||
|
expect(migrator.getAppliedMigrations()).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
db.get<{ name: string }>(
|
||||||
|
"SELECT name FROM sqlite_master WHERE name='will_exist'"
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns applied migrations ordered by filename', () => {
|
||||||
|
writeMigration(migrationsDir, '001_a.sql', 'CREATE TABLE a (id INTEGER);');
|
||||||
|
writeMigration(migrationsDir, '002_b.sql', 'CREATE TABLE b (id INTEGER);');
|
||||||
|
const migrator = new Migrator(db, migrationsDir);
|
||||||
|
|
||||||
|
migrator.migrate();
|
||||||
|
|
||||||
|
const applied = migrator.getAppliedMigrations();
|
||||||
|
expect(applied).toHaveLength(2);
|
||||||
|
expect(applied[0].filename).toBe('001_a.sql');
|
||||||
|
expect(applied[1].filename).toBe('002_b.sql');
|
||||||
|
expect(applied[0].applied_at).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only reads .sql files in the migrations directory', () => {
|
||||||
|
writeMigration(
|
||||||
|
migrationsDir,
|
||||||
|
'001_real.sql',
|
||||||
|
'CREATE TABLE real_t (id INTEGER);'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(migrationsDir, 'README.md'), 'not a migration');
|
||||||
|
const migrator = new Migrator(db, migrationsDir);
|
||||||
|
|
||||||
|
migrator.migrate();
|
||||||
|
|
||||||
|
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
|
||||||
|
'001_real.sql',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the real 001_initial.sql and creates all core tables', () => {
|
||||||
|
const realMigrationsDir = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'lib',
|
||||||
|
'db',
|
||||||
|
'migrations'
|
||||||
|
);
|
||||||
|
const migrator = new Migrator(db, realMigrationsDir);
|
||||||
|
|
||||||
|
migrator.migrate();
|
||||||
|
|
||||||
|
const tables = db
|
||||||
|
.query<{
|
||||||
|
name: string;
|
||||||
|
}>("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||||
|
.map((t) => t.name);
|
||||||
|
|
||||||
|
const expectedTables = [
|
||||||
|
'activity_logs',
|
||||||
|
'board_comments',
|
||||||
|
'board_threads',
|
||||||
|
'calendar_events',
|
||||||
|
'chat_messages',
|
||||||
|
'file_assets',
|
||||||
|
'meeting_members',
|
||||||
|
'meetings',
|
||||||
|
'milestones',
|
||||||
|
'notifications',
|
||||||
|
'project_members',
|
||||||
|
'project_notes',
|
||||||
|
'projects',
|
||||||
|
'schema_migrations',
|
||||||
|
'todo_columns',
|
||||||
|
'todo_items',
|
||||||
|
'users',
|
||||||
|
];
|
||||||
|
for (const table of expectedTables) {
|
||||||
|
expect(tables).toContain(table);
|
||||||
|
}
|
||||||
|
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
|
||||||
|
'001_initial.sql',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
252
tests/unit/lib/db/sqlite.test.ts
Normal file
252
tests/unit/lib/db/sqlite.test.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { createTestDb } from '@/tests/helpers/db';
|
||||||
|
import { SqliteDatabase } from '@/lib/db/sqlite';
|
||||||
|
|
||||||
|
describe('SqliteDatabase', () => {
|
||||||
|
let db: SqliteDatabase;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (db) db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('enables WAL journal mode', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
const row = db.get<{ journal_mode: string }>('PRAGMA journal_mode');
|
||||||
|
expect(row?.journal_mode).toBe('wal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enables foreign key constraints', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
const row = db.get<{ foreign_keys: number }>('PRAGMA foreign_keys');
|
||||||
|
expect(row?.foreign_keys).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('query', () => {
|
||||||
|
it('returns multiple rows', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||||
|
|
||||||
|
const rows = db.query<{ id: number; name: string }>(
|
||||||
|
'SELECT * FROM items ORDER BY id'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].name).toBe('a');
|
||||||
|
expect(rows[1].name).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no rows match', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = db.query<{ id: number }>('SELECT * FROM items');
|
||||||
|
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds named parameters', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('target')");
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('other')");
|
||||||
|
|
||||||
|
const rows = db.query<{ name: string }>(
|
||||||
|
'SELECT * FROM items WHERE name = @name',
|
||||||
|
{ name: 'target' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].name).toBe('target');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
it('returns a single row when found', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
const { lastInsertRowid } = db.execute(
|
||||||
|
"INSERT INTO items (name) VALUES ('x')"
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = db.get<{ id: number; name: string }>(
|
||||||
|
'SELECT * FROM items WHERE id = @id',
|
||||||
|
{ id: Number(lastInsertRowid) }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
expect(row?.name).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no row is found', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = db.get<{ id: number }>('SELECT * FROM items WHERE id = @id', {
|
||||||
|
id: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('returns changes and lastInsertRowid for an insert', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||||
|
|
||||||
|
expect(result.changes).toBe(1);
|
||||||
|
expect(Number(result.lastInsertRowid)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the number of affected rows for an update', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||||
|
|
||||||
|
const result = db.execute(
|
||||||
|
'UPDATE items SET name = @name WHERE name = @old',
|
||||||
|
{ name: 'updated', old: 'a' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.changes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the number of deleted rows for a delete', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||||
|
|
||||||
|
const result = db.execute('DELETE FROM items WHERE name = @name', {
|
||||||
|
name: 'a',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changes).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exec', () => {
|
||||||
|
it('executes multiple semicolon-separated statements', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE a (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
||||||
|
CREATE TABLE b (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);
|
||||||
|
INSERT INTO a (name) VALUES ('first');
|
||||||
|
INSERT INTO b (value) VALUES (42);
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(db.get<{ name: string }>('SELECT name FROM a')?.name).toBe(
|
||||||
|
'first'
|
||||||
|
);
|
||||||
|
expect(db.get<{ value: number }>('SELECT value FROM b')?.value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles SQL comments alongside statements', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
-- this is a comment
|
||||||
|
CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
||||||
|
/* multi-line
|
||||||
|
comment */
|
||||||
|
INSERT INTO items (name) VALUES ('x');
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(db.query<{ name: string }>('SELECT name FROM items')).toHaveLength(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transaction', () => {
|
||||||
|
it('commits changes when the callback succeeds', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = db.query<{ name: string }>(
|
||||||
|
'SELECT * FROM items ORDER BY id'
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back changes when the callback throws', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
db.transaction(() => {
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('b')");
|
||||||
|
throw new Error('boom');
|
||||||
|
})
|
||||||
|
).toThrow('boom');
|
||||||
|
|
||||||
|
const rows = db.query<{ name: string }>('SELECT * FROM items');
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the callback value', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = db.transaction(() => {
|
||||||
|
db.execute("INSERT INTO items (name) VALUES ('a')");
|
||||||
|
return 'committed';
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('committed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('foreign key enforcement', () => {
|
||||||
|
it('rejects inserts that violate a foreign key constraint', () => {
|
||||||
|
db = createTestDb();
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE parents (id INTEGER PRIMARY KEY, name TEXT NOT NULL)'
|
||||||
|
);
|
||||||
|
db.execute(
|
||||||
|
'CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL, FOREIGN KEY (parent_id) REFERENCES parents(id))'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
db.execute('INSERT INTO children (parent_id) VALUES (@parentId)', {
|
||||||
|
parentId: 999,
|
||||||
|
})
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user