Add comprehensive LLM development documentation (#939)
* Increase coverage * Add comprehensive LLM development documentation - Add CLAUDE.md as main documentation index - Create 8 detailed documentation files in docs/: - architecture.md: Tech stack, data models, auth system - directory-structure.md: Complete file tree with paths - backend-patterns.md: Module architecture and patterns - database.md: Models, migrations, and workflows - development-workflow.md: Setup and daily development - code-conventions.md: Style guide and best practices - testing.md: Test organization and patterns - common-tasks.md: How-to guides for frequent tasks - Update .gitignore to allow project-level CLAUDE.md - 4,285 lines of comprehensive documentation - Organized for easy navigation with cross-links - LLM-optimized with absolute paths and code examples * fixup! Add comprehensive LLM development documentation
This commit is contained in:
parent
25b11086e2
commit
3486541272
19 changed files with 6086 additions and 2 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -6,8 +6,8 @@ certs/
|
|||
.DS_Store
|
||||
.cursor
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
CLAUDE.local.md
|
||||
# CLAUDE.md - project documentation (committed)
|
||||
CLAUDE.local.md # User-specific customizations (ignored)
|
||||
|
||||
.byebug_history
|
||||
node_modules
|
||||
|
|
|
|||
166
CLAUDE.md
Normal file
166
CLAUDE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Tududi - Developer Guide
|
||||
|
||||
This documentation is designed for AI assistants and developers working with the tududi codebase. For user-facing documentation, see [README.md](README.md). For contribution guidelines, see [CONTRIBUTING.md](.github/CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Tududi is a self-hosted task management system with hierarchical organization (Areas > Projects > Tasks), smart recurring tasks, and multi-channel integration.
|
||||
|
||||
**Tech Stack:** React 18 + TypeScript, Express + Sequelize, SQLite
|
||||
|
||||
**Get Started:**
|
||||
```bash
|
||||
git clone https://github.com/chrisvel/tududi.git
|
||||
cd tududi
|
||||
npm install
|
||||
npm run db:init
|
||||
npm start # Frontend on :8080, Backend on :3002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Core Documentation
|
||||
|
||||
1. **[Architecture Overview](docs/architecture.md)**
|
||||
- Tech stack details
|
||||
- Request flow diagram
|
||||
- Data model hierarchy
|
||||
- Authentication methods
|
||||
|
||||
2. **[Directory Structure](docs/directory-structure.md)**
|
||||
- Complete file tree with absolute paths
|
||||
- Critical paths reference
|
||||
- Backend and frontend organization
|
||||
|
||||
3. **[Backend Patterns](docs/backend-patterns.md)**
|
||||
- Module architecture pattern
|
||||
- How to add new modules
|
||||
- Module communication
|
||||
- Repository and service patterns
|
||||
|
||||
4. **[Database & Migrations](docs/database.md)**
|
||||
- Key models and relationships
|
||||
- Migration workflow
|
||||
- Migration best practices
|
||||
- Common migration operations
|
||||
|
||||
5. **[Development Workflow](docs/development-workflow.md)**
|
||||
- Initial setup
|
||||
- Daily development (two-server process)
|
||||
- Environment variables
|
||||
- Adding new features (complete walkthrough)
|
||||
- Database management commands
|
||||
|
||||
6. **[Code Conventions](docs/code-conventions.md)**
|
||||
- Language usage (TypeScript/JavaScript)
|
||||
- Backend patterns (async/await, repository)
|
||||
- Frontend patterns (components, state)
|
||||
- Naming conventions
|
||||
- API route conventions
|
||||
|
||||
7. **[Testing](docs/testing.md)**
|
||||
- Test organization
|
||||
- Running tests
|
||||
- Testing requirements
|
||||
- Test patterns (Arrange-Act-Assert)
|
||||
|
||||
8. **[Common Tasks](docs/common-tasks.md)**
|
||||
- Add field to model
|
||||
- Create new backend module
|
||||
- Add React component
|
||||
- Update database schema
|
||||
- Fix a bug (TDD workflow)
|
||||
- Add translations
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
### What This Project Does
|
||||
|
||||
Tududi is a self-hosted task management system designed around hierarchical organization and smart automation. It prioritizes user flow over rigid structures - a productivity tool that doesn't "fight back."
|
||||
|
||||
**Core Philosophy:**
|
||||
- [Designing a Life Management System That Doesn't Fight Back](https://medium.com/@chrisveleris/designing-a-life-management-system-that-doesnt-fight-back-2fd58773e857)
|
||||
- [From Task to Table: How I Finally Got to the Korean Burger](https://medium.com/@chrisveleris/from-task-to-table-how-i-finally-got-to-the-korean-burger-01245a14d491)
|
||||
|
||||
**Key Capabilities:**
|
||||
- **Hierarchical Organization:** Areas > Projects > Tasks > Subtasks
|
||||
- **Smart Recurring Tasks:** Multiple patterns with parent-child tracking
|
||||
- **Multi-Language Support:** 24 languages via i18next
|
||||
- **Collaboration:** Project sharing with granular permissions
|
||||
- **REST API:** Swagger docs + personal API tokens
|
||||
- **Telegram Integration:** Create tasks via messages, daily digests
|
||||
- **Tag System:** Flexible tagging across tasks, notes, projects
|
||||
|
||||
**Target Users:** Self-hosting individuals and teams managing personal or collaborative productivity
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
**Frontend:**
|
||||
- React 18 + TypeScript 5.6
|
||||
- Webpack 5 (build) + webpack-dev-server (development)
|
||||
- Tailwind CSS 3.4 + Heroicons
|
||||
- Zustand (global state) + SWR (server state)
|
||||
- React Router 6, i18next (24 languages)
|
||||
|
||||
**Backend:**
|
||||
- Express 4.21 + Sequelize 6.37 (ORM)
|
||||
- SQLite 5.1 (WAL mode, optimized)
|
||||
- bcrypt + express-session (auth)
|
||||
- Swagger (API docs), Multer (uploads)
|
||||
- node-cron (scheduling), Nodemailer (email)
|
||||
|
||||
**Testing:**
|
||||
- Jest (backend + frontend)
|
||||
- Playwright (E2E)
|
||||
- Supertest (API integration tests)
|
||||
|
||||
---
|
||||
|
||||
## Critical Paths Quick Reference
|
||||
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
| Add backend feature | `/backend/modules/[feature]/` |
|
||||
| Create model | `/backend/models/[model].js` |
|
||||
| Database migration | `/backend/migrations/` |
|
||||
| React component | `/frontend/components/[Feature]/` |
|
||||
| API routes | `/backend/modules/[module]/routes.js` |
|
||||
| Global state | `/frontend/store/useStore.ts` |
|
||||
| API client | `/frontend/utils/[resource]Service.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
| Document | Audience | Purpose |
|
||||
|----------|----------|---------|
|
||||
| [README.md](README.md) | Users | Features, Docker setup, quick start |
|
||||
| [CONTRIBUTING.md](.github/CONTRIBUTING.md) | Contributors | PR workflow, code of conduct |
|
||||
| [docs.tududi.com](https://docs.tududi.com) | End users | Full user documentation |
|
||||
| [Swagger API docs](http://localhost:3002/api-docs) | API consumers | API endpoints (after auth) |
|
||||
| **CLAUDE.md** | Developers, AI | Codebase architecture, patterns |
|
||||
|
||||
---
|
||||
|
||||
## External Resources
|
||||
|
||||
- **Roadmap:** [GitHub Project](https://github.com/users/chrisvel/projects/2)
|
||||
- **Community:**
|
||||
- [Discord](https://discord.gg/fkbeJ9CmcH)
|
||||
- [Reddit](https://www.reddit.com/r/tududi/)
|
||||
- [Issues](https://github.com/chrisvel/tududi/issues)
|
||||
- [Discussions](https://github.com/chrisvel/tududi/discussions)
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0.0
|
||||
**Last Updated:** 2026-03-13
|
||||
**Maintainer:** Update when architecture changes or patterns evolve
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
const { requireAuth } = require('../../../middleware/auth');
|
||||
const { User } = require('../../../models');
|
||||
const { createApiToken } = require('../../../modules/users/apiTokenService');
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
let req, res, next;
|
||||
|
|
@ -135,4 +136,154 @@ describe('Auth Middleware', () => {
|
|||
User.findByPk = originalFindByPk;
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
// --- API Token (Bearer) authentication ---
|
||||
|
||||
describe('Bearer token authentication', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { sequelize } = require('../../../models');
|
||||
await sequelize.query('DELETE FROM api_tokens');
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'token-user@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
// No session – forces the bearer path
|
||||
req.session = null;
|
||||
req.headers = {};
|
||||
});
|
||||
|
||||
it('should authenticate with a valid Bearer token', async () => {
|
||||
const { rawToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.currentUser.id).toBe(user.id);
|
||||
expect(req.authToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 401 for an invalid Bearer token', async () => {
|
||||
req.headers = { authorization: 'Bearer invalid-token-value' };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid or expired API token',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for a revoked token', async () => {
|
||||
const { rawToken, tokenRecord } = await createApiToken({
|
||||
userId: user.id,
|
||||
name: 'revoked',
|
||||
});
|
||||
await tokenRecord.update({ revoked_at: new Date() });
|
||||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid or expired API token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 for an expired token', async () => {
|
||||
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const { rawToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
name: 'expired',
|
||||
expiresAt: pastDate,
|
||||
});
|
||||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid or expired API token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when Authorization header has no token value', async () => {
|
||||
req.headers = { authorization: 'Bearer ' };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for non-Bearer scheme', async () => {
|
||||
req.headers = { authorization: 'Basic abc123' };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when token user no longer exists', async () => {
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const { rawToken } = await createApiToken({
|
||||
userId: user.id,
|
||||
name: 'orphan',
|
||||
});
|
||||
// Destroying the user cascade-deletes associated tokens,
|
||||
// so the token lookup itself fails rather than the user lookup
|
||||
await user.destroy();
|
||||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('should update last_used_at when token has not been used recently', async () => {
|
||||
const { rawToken, tokenRecord } = await createApiToken({
|
||||
userId: user.id,
|
||||
name: 'fresh',
|
||||
});
|
||||
// Ensure last_used_at is old enough to trigger update
|
||||
await tokenRecord.update({
|
||||
last_used_at: new Date(Date.now() - 10 * 60 * 1000),
|
||||
});
|
||||
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
// Give the fire-and-forget update a moment to complete
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await tokenRecord.reload();
|
||||
// last_used_at should be updated to roughly now
|
||||
expect(
|
||||
Date.now() - tokenRecord.last_used_at.getTime()
|
||||
).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('should skip health check even with no session and no token', async () => {
|
||||
req.path = '/api/health';
|
||||
req.headers = {};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
265
backend/tests/unit/middleware/authorize.test.js
Normal file
265
backend/tests/unit/middleware/authorize.test.js
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
const { hasAccess } = require('../../../middleware/authorize');
|
||||
const permissionsService = require('../../../services/permissionsService');
|
||||
|
||||
jest.mock('../../../services/permissionsService');
|
||||
|
||||
describe('authorize middleware – hasAccess', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
currentUser: { id: 42 },
|
||||
session: { userId: 42 },
|
||||
params: { uid: 'abc123' },
|
||||
};
|
||||
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- UID resolution ---
|
||||
|
||||
it('should resolve uid from a function', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('ro', 'project', (r) => r.params.uid);
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||
42,
|
||||
'project',
|
||||
'abc123'
|
||||
);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve uid from a static string', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('ro', 'project', 'static-uid');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||
42,
|
||||
'project',
|
||||
'static-uid'
|
||||
);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve uid from an async function', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('ro', 'task', async (r) => r.params.uid);
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||
42,
|
||||
'task',
|
||||
'abc123'
|
||||
);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 when uid resolves to null', async () => {
|
||||
const mw = hasAccess('ro', 'project', () => null);
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 when uid resolves to undefined', async () => {
|
||||
const mw = hasAccess('ro', 'project', () => undefined);
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 when uid resolves to empty string', async () => {
|
||||
const mw = hasAccess('ro', 'project', () => '');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Permission hierarchy ---
|
||||
|
||||
it('should allow access when user has exact required level (ro)', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('ro');
|
||||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow access when user has higher than required (rw > ro)', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow access when user has admin and rw is required', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('admin');
|
||||
const mw = hasAccess('rw', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deny access when user has ro but rw is required', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('ro');
|
||||
const mw = hasAccess('rw', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deny access when user has none', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('none');
|
||||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- User ID extraction ---
|
||||
|
||||
it('should prefer currentUser.id over session.userId', async () => {
|
||||
req.currentUser = { id: 100 };
|
||||
req.session = { userId: 200 };
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||
100,
|
||||
'project',
|
||||
'uid1'
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to session.userId when currentUser is missing', async () => {
|
||||
req.currentUser = null;
|
||||
req.session = { userId: 200 };
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||
200,
|
||||
'project',
|
||||
'uid1'
|
||||
);
|
||||
});
|
||||
|
||||
// --- Options ---
|
||||
|
||||
it('should use custom notFoundMessage', async () => {
|
||||
const mw = hasAccess('ro', 'project', () => null, {
|
||||
notFoundMessage: 'Project not found',
|
||||
});
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' });
|
||||
});
|
||||
|
||||
it('should return 404 instead of 403 when forbiddenStatus is 404', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('none');
|
||||
const mw = hasAccess('rw', 'project', () => 'uid1', {
|
||||
forbiddenStatus: 404,
|
||||
});
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
|
||||
});
|
||||
|
||||
it('should use custom notFoundMessage with forbiddenStatus 404', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('none');
|
||||
const mw = hasAccess('rw', 'project', () => 'uid1', {
|
||||
forbiddenStatus: 404,
|
||||
notFoundMessage: 'Task not found',
|
||||
});
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Task not found' });
|
||||
});
|
||||
|
||||
// --- Resource types ---
|
||||
|
||||
it('should pass correct resource type for tasks', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('rw', 'task', () => 'task-uid');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||
42,
|
||||
'task',
|
||||
'task-uid'
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass correct resource type for notes', async () => {
|
||||
permissionsService.getAccess.mockResolvedValue('rw');
|
||||
const mw = hasAccess('rw', 'note', () => 'note-uid');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||
42,
|
||||
'note',
|
||||
'note-uid'
|
||||
);
|
||||
});
|
||||
|
||||
// --- Error handling ---
|
||||
|
||||
it('should forward errors from getAccess to next()', async () => {
|
||||
const error = new Error('Database failure');
|
||||
permissionsService.getAccess.mockRejectedValue(error);
|
||||
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(error);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should forward errors from getResourceUid to next()', async () => {
|
||||
const error = new Error('UID lookup failed');
|
||||
const mw = hasAccess('ro', 'project', () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await mw(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(error);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
258
backend/tests/unit/services/applyPerms.test.js
Normal file
258
backend/tests/unit/services/applyPerms.test.js
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
const { applyPerms } = require('../../../services/applyPerms');
|
||||
const { sequelize, Permission, User } = require('../../../models');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
describe('applyPerms', () => {
|
||||
let owner, target;
|
||||
|
||||
beforeEach(async () => {
|
||||
await sequelize.query('DELETE FROM permissions');
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
owner = await User.create({
|
||||
email: 'owner@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
target = await User.create({
|
||||
email: 'target@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a new permission on upsert when none exists', async () => {
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, {
|
||||
upserts: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'project',
|
||||
resourceUid: 'proj-uid-1',
|
||||
accessLevel: 'ro',
|
||||
propagation: 'direct',
|
||||
grantedByUserId: owner.id,
|
||||
},
|
||||
],
|
||||
deletes: [],
|
||||
});
|
||||
});
|
||||
|
||||
const perm = await Permission.findOne({
|
||||
where: {
|
||||
user_id: target.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: 'proj-uid-1',
|
||||
},
|
||||
});
|
||||
expect(perm).not.toBeNull();
|
||||
expect(perm.access_level).toBe('ro');
|
||||
expect(perm.propagation).toBe('direct');
|
||||
expect(perm.granted_by_user_id).toBe(owner.id);
|
||||
});
|
||||
|
||||
it('should upgrade access level on upsert when permission already exists (ro -> rw)', async () => {
|
||||
// Create existing ro permission
|
||||
await Permission.create({
|
||||
user_id: target.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: 'proj-uid-2',
|
||||
access_level: 'ro',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, {
|
||||
upserts: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'project',
|
||||
resourceUid: 'proj-uid-2',
|
||||
accessLevel: 'rw',
|
||||
grantedByUserId: owner.id,
|
||||
},
|
||||
],
|
||||
deletes: [],
|
||||
});
|
||||
});
|
||||
|
||||
const perm = await Permission.findOne({
|
||||
where: {
|
||||
user_id: target.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: 'proj-uid-2',
|
||||
},
|
||||
});
|
||||
expect(perm.access_level).toBe('rw');
|
||||
});
|
||||
|
||||
it('should keep rw when upserting ro on existing rw permission', async () => {
|
||||
await Permission.create({
|
||||
user_id: target.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: 'proj-uid-3',
|
||||
access_level: 'rw',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, {
|
||||
upserts: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'project',
|
||||
resourceUid: 'proj-uid-3',
|
||||
accessLevel: 'ro',
|
||||
grantedByUserId: owner.id,
|
||||
},
|
||||
],
|
||||
deletes: [],
|
||||
});
|
||||
});
|
||||
|
||||
const perm = await Permission.findOne({
|
||||
where: {
|
||||
user_id: target.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: 'proj-uid-3',
|
||||
},
|
||||
});
|
||||
expect(perm.access_level).toBe('rw');
|
||||
});
|
||||
|
||||
it('should delete a permission', async () => {
|
||||
await Permission.create({
|
||||
user_id: target.id,
|
||||
resource_type: 'task',
|
||||
resource_uid: 'task-uid-1',
|
||||
access_level: 'rw',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, {
|
||||
upserts: [],
|
||||
deletes: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'task',
|
||||
resourceUid: 'task-uid-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const perm = await Permission.findOne({
|
||||
where: {
|
||||
user_id: target.id,
|
||||
resource_type: 'task',
|
||||
resource_uid: 'task-uid-1',
|
||||
},
|
||||
});
|
||||
expect(perm).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty upserts and deletes', async () => {
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, { upserts: [], deletes: [] });
|
||||
});
|
||||
// No error thrown
|
||||
});
|
||||
|
||||
it('should handle multiple upserts and deletes in one call', async () => {
|
||||
await Permission.create({
|
||||
user_id: target.id,
|
||||
resource_type: 'note',
|
||||
resource_uid: 'note-to-delete',
|
||||
access_level: 'ro',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, {
|
||||
upserts: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'project',
|
||||
resourceUid: 'new-proj',
|
||||
accessLevel: 'rw',
|
||||
propagation: 'direct',
|
||||
grantedByUserId: owner.id,
|
||||
},
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'task',
|
||||
resourceUid: 'new-task',
|
||||
accessLevel: 'ro',
|
||||
propagation: 'inherited',
|
||||
grantedByUserId: owner.id,
|
||||
},
|
||||
],
|
||||
deletes: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'note',
|
||||
resourceUid: 'note-to-delete',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const projPerm = await Permission.findOne({
|
||||
where: { user_id: target.id, resource_uid: 'new-proj' },
|
||||
});
|
||||
const taskPerm = await Permission.findOne({
|
||||
where: { user_id: target.id, resource_uid: 'new-task' },
|
||||
});
|
||||
const notePerm = await Permission.findOne({
|
||||
where: { user_id: target.id, resource_uid: 'note-to-delete' },
|
||||
});
|
||||
|
||||
expect(projPerm).not.toBeNull();
|
||||
expect(projPerm.access_level).toBe('rw');
|
||||
expect(taskPerm).not.toBeNull();
|
||||
expect(taskPerm.access_level).toBe('ro');
|
||||
expect(taskPerm.propagation).toBe('inherited');
|
||||
expect(notePerm).toBeNull();
|
||||
});
|
||||
|
||||
it('should set default propagation to direct when not specified', async () => {
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, {
|
||||
upserts: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'project',
|
||||
resourceUid: 'proj-default',
|
||||
accessLevel: 'ro',
|
||||
grantedByUserId: owner.id,
|
||||
// no propagation
|
||||
},
|
||||
],
|
||||
deletes: [],
|
||||
});
|
||||
});
|
||||
|
||||
const perm = await Permission.findOne({
|
||||
where: { user_id: target.id, resource_uid: 'proj-default' },
|
||||
});
|
||||
expect(perm.propagation).toBe('direct');
|
||||
});
|
||||
|
||||
it('should not fail when deleting a non-existent permission', async () => {
|
||||
await sequelize.transaction(async (tx) => {
|
||||
await applyPerms(tx, {
|
||||
upserts: [],
|
||||
deletes: [
|
||||
{
|
||||
userId: target.id,
|
||||
resourceType: 'project',
|
||||
resourceUid: 'does-not-exist',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
// Should not throw
|
||||
});
|
||||
});
|
||||
332
backend/tests/unit/services/permissionsService.test.js
Normal file
332
backend/tests/unit/services/permissionsService.test.js
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
const {
|
||||
getAccess,
|
||||
getSharedUidsForUser,
|
||||
ownershipOrPermissionWhere,
|
||||
ACCESS,
|
||||
} = require('../../../services/permissionsService');
|
||||
const {
|
||||
User,
|
||||
Project,
|
||||
Task,
|
||||
Note,
|
||||
Permission,
|
||||
sequelize,
|
||||
} = require('../../../models');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
describe('permissionsService', () => {
|
||||
let owner, otherUser, adminUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
await sequelize.query('DELETE FROM permissions');
|
||||
await sequelize.query('DELETE FROM roles');
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
// First user created gets admin role automatically via afterCreate hook
|
||||
adminUser = await User.create({
|
||||
email: 'admin@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
owner = await User.create({
|
||||
email: 'owner@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
otherUser = await User.create({
|
||||
email: 'other@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccess', () => {
|
||||
// --- Projects ---
|
||||
|
||||
it('should return rw for project owner', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
const access = await getAccess(owner.id, 'project', project.uid);
|
||||
expect(access).toBe('rw');
|
||||
});
|
||||
|
||||
it('should return none for non-owner without permission', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
const access = await getAccess(
|
||||
otherUser.id,
|
||||
'project',
|
||||
project.uid
|
||||
);
|
||||
expect(access).toBe('none');
|
||||
});
|
||||
|
||||
it('should return admin for admin user', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
const access = await getAccess(
|
||||
adminUser.uid,
|
||||
'project',
|
||||
project.uid
|
||||
);
|
||||
expect(access).toBe('admin');
|
||||
});
|
||||
|
||||
it('should return none for non-existent project', async () => {
|
||||
const access = await getAccess(
|
||||
owner.id,
|
||||
'project',
|
||||
'nonexistent-uid'
|
||||
);
|
||||
expect(access).toBe('none');
|
||||
});
|
||||
|
||||
it('should return shared permission level for project', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: project.uid,
|
||||
access_level: 'ro',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
const access = await getAccess(
|
||||
otherUser.id,
|
||||
'project',
|
||||
project.uid
|
||||
);
|
||||
expect(access).toBe('ro');
|
||||
});
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
it('should return rw for task owner', async () => {
|
||||
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
||||
const access = await getAccess(owner.id, 'task', task.uid);
|
||||
expect(access).toBe('rw');
|
||||
});
|
||||
|
||||
it('should return none for non-owner task without permission', async () => {
|
||||
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
||||
const access = await getAccess(otherUser.id, 'task', task.uid);
|
||||
expect(access).toBe('none');
|
||||
});
|
||||
|
||||
it('should return none for non-existent task', async () => {
|
||||
const access = await getAccess(owner.id, 'task', 'no-such-task');
|
||||
expect(access).toBe('none');
|
||||
});
|
||||
|
||||
it('should inherit task access from parent project permission', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
const task = await Task.create({
|
||||
name: 'T1',
|
||||
user_id: owner.id,
|
||||
project_id: project.id,
|
||||
});
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: project.uid,
|
||||
access_level: 'rw',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
const access = await getAccess(otherUser.id, 'task', task.uid);
|
||||
expect(access).toBe('rw');
|
||||
});
|
||||
|
||||
it('should return shared permission for directly shared task', async () => {
|
||||
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'task',
|
||||
resource_uid: task.uid,
|
||||
access_level: 'ro',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
const access = await getAccess(otherUser.id, 'task', task.uid);
|
||||
expect(access).toBe('ro');
|
||||
});
|
||||
|
||||
// --- Notes ---
|
||||
|
||||
it('should return rw for note owner', async () => {
|
||||
const note = await Note.create({ title: 'N1', user_id: owner.id });
|
||||
const access = await getAccess(owner.id, 'note', note.uid);
|
||||
expect(access).toBe('rw');
|
||||
});
|
||||
|
||||
it('should return none for non-owner note without permission', async () => {
|
||||
const note = await Note.create({ title: 'N1', user_id: owner.id });
|
||||
const access = await getAccess(otherUser.id, 'note', note.uid);
|
||||
expect(access).toBe('none');
|
||||
});
|
||||
|
||||
it('should inherit note access from parent project permission', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
const note = await Note.create({
|
||||
title: 'N1',
|
||||
user_id: owner.id,
|
||||
project_id: project.id,
|
||||
});
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: project.uid,
|
||||
access_level: 'ro',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
const access = await getAccess(otherUser.id, 'note', note.uid);
|
||||
expect(access).toBe('ro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedUidsForUser', () => {
|
||||
it('should return empty array when no permissions exist', async () => {
|
||||
const uids = await getSharedUidsForUser('project', otherUser.id);
|
||||
expect(uids).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return shared resource uids', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: project.uid,
|
||||
access_level: 'ro',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
const uids = await getSharedUidsForUser('project', otherUser.id);
|
||||
expect(uids).toContain(project.uid);
|
||||
});
|
||||
|
||||
it('should deduplicate uids', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
// Create two permissions for the same resource (different propagation)
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: project.uid,
|
||||
access_level: 'ro',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
const uids = await getSharedUidsForUser('project', otherUser.id);
|
||||
const uniqueUids = [...new Set(uids)];
|
||||
expect(uids.length).toBe(uniqueUids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownershipOrPermissionWhere', () => {
|
||||
it('should include user_id condition for owned resources', async () => {
|
||||
const where = await ownershipOrPermissionWhere('project', owner.id);
|
||||
expect(where).toBeDefined();
|
||||
// Should contain an Op.or with user_id condition
|
||||
const orKey = Object.getOwnPropertySymbols(where)[0];
|
||||
expect(orKey).toBeDefined();
|
||||
const conditions = where[orKey];
|
||||
expect(conditions.some((c) => c.user_id === owner.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should include shared resource uids when permissions exist', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Shared',
|
||||
user_id: owner.id,
|
||||
});
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: project.uid,
|
||||
access_level: 'rw',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
|
||||
const where = await ownershipOrPermissionWhere(
|
||||
'project',
|
||||
otherUser.id
|
||||
);
|
||||
const orKey = Object.getOwnPropertySymbols(where)[0];
|
||||
const conditions = where[orKey];
|
||||
// Should have a uid IN condition with the shared project uid
|
||||
const uidCondition = conditions.find((c) => c.uid);
|
||||
expect(uidCondition).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use cache when provided', async () => {
|
||||
const cache = new Map();
|
||||
const where1 = await ownershipOrPermissionWhere(
|
||||
'project',
|
||||
owner.id,
|
||||
cache
|
||||
);
|
||||
const where2 = await ownershipOrPermissionWhere(
|
||||
'project',
|
||||
owner.id,
|
||||
cache
|
||||
);
|
||||
expect(where1).toBe(where2); // same reference from cache
|
||||
});
|
||||
|
||||
it('should include tasks from shared projects for task resource type', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'P1',
|
||||
user_id: owner.id,
|
||||
});
|
||||
await Task.create({
|
||||
name: 'T1',
|
||||
user_id: owner.id,
|
||||
project_id: project.id,
|
||||
});
|
||||
await Permission.create({
|
||||
user_id: otherUser.id,
|
||||
resource_type: 'project',
|
||||
resource_uid: project.uid,
|
||||
access_level: 'rw',
|
||||
propagation: 'direct',
|
||||
granted_by_user_id: owner.id,
|
||||
});
|
||||
|
||||
const where = await ownershipOrPermissionWhere(
|
||||
'task',
|
||||
otherUser.id
|
||||
);
|
||||
const orKey = Object.getOwnPropertySymbols(where)[0];
|
||||
const conditions = where[orKey];
|
||||
// Should have a project_id IN condition
|
||||
const projectCondition = conditions.find((c) => c.project_id);
|
||||
expect(projectCondition).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACCESS constants', () => {
|
||||
it('should export expected access levels', () => {
|
||||
expect(ACCESS.NONE).toBe('none');
|
||||
expect(ACCESS.RO).toBe('ro');
|
||||
expect(ACCESS.RW).toBe('rw');
|
||||
expect(ACCESS.ADMIN).toBe('admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
84
backend/tests/unit/services/rolesService.test.js
Normal file
84
backend/tests/unit/services/rolesService.test.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
const { isAdmin } = require('../../../services/rolesService');
|
||||
const { User, Role, sequelize } = require('../../../models');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
describe('rolesService', () => {
|
||||
beforeEach(async () => {
|
||||
await sequelize.query('DELETE FROM roles');
|
||||
});
|
||||
|
||||
describe('isAdmin', () => {
|
||||
it('should return false for null uid', async () => {
|
||||
expect(await isAdmin(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined uid', async () => {
|
||||
expect(await isAdmin(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string uid', async () => {
|
||||
expect(await isAdmin('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user does not exist', async () => {
|
||||
expect(await isAdmin('nonexistent-uid')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for first user (auto-admin via afterCreate hook)', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
// First user created when no admin exists becomes admin automatically
|
||||
const user = await User.create({
|
||||
email: 'first@example.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
expect(await isAdmin(user.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-first user (non-admin)', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
// First user becomes admin
|
||||
await User.create({
|
||||
email: 'first@example.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
// Second user is not admin
|
||||
const second = await User.create({
|
||||
email: 'second@example.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
expect(await isAdmin(second.uid)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user role has is_admin=false', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
const user = await User.create({
|
||||
email: 'demoted@example.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
// The hook created an admin role; update it to non-admin
|
||||
await Role.update(
|
||||
{ is_admin: false },
|
||||
{ where: { user_id: user.id } }
|
||||
);
|
||||
expect(await isAdmin(user.uid)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when user role has is_admin=true', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
await User.create({
|
||||
email: 'first@example.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const user = await User.create({
|
||||
email: 'promoted@example.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
// The hook created a non-admin role; update it to admin
|
||||
await Role.update(
|
||||
{ is_admin: true },
|
||||
{ where: { user_id: user.id } }
|
||||
);
|
||||
expect(await isAdmin(user.uid)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
154
backend/tests/unit/shared/BaseRepository.test.js
Normal file
154
backend/tests/unit/shared/BaseRepository.test.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
const BaseRepository = require('../../../shared/database/BaseRepository');
|
||||
const { User } = require('../../../models');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
describe('BaseRepository', () => {
|
||||
let repo;
|
||||
|
||||
beforeAll(() => {
|
||||
repo = new BaseRepository(User);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a record', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
const user = await repo.create({
|
||||
email: 'base-repo@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
expect(user.id).toBeDefined();
|
||||
expect(user.email).toBe('base-repo@test.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find a record by primary key', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
const created = await repo.create({
|
||||
email: 'findbyid@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found.email).toBe('findbyid@test.com');
|
||||
});
|
||||
|
||||
it('should return null for non-existent id', async () => {
|
||||
const found = await repo.findById(999999);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find a record by where clause', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
await repo.create({
|
||||
email: 'findone@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const found = await repo.findOne({ email: 'findone@test.com' });
|
||||
expect(found).not.toBeNull();
|
||||
expect(found.email).toBe('findone@test.com');
|
||||
});
|
||||
|
||||
it('should return null when no match', async () => {
|
||||
const found = await repo.findOne({ email: 'nonexistent@test.com' });
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all matching records', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
await repo.create({
|
||||
email: 'findall1@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
await repo.create({
|
||||
email: 'findall2@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const all = await repo.findAll();
|
||||
expect(all.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should filter with where clause', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
await repo.create({
|
||||
email: 'specific@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const results = await repo.findAll({ email: 'specific@test.com' });
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].email).toBe('specific@test.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update instance fields', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
const user = await repo.create({
|
||||
email: 'update@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
await repo.update(user, { email: 'updated@test.com' });
|
||||
await user.reload();
|
||||
expect(user.email).toBe('updated@test.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should delete a record', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
const user = await repo.create({
|
||||
email: 'destroy@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const id = user.id;
|
||||
await repo.destroy(user);
|
||||
const found = await repo.findById(id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('count', () => {
|
||||
it('should count all records', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
await repo.create({
|
||||
email: 'count1@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const c = await repo.count();
|
||||
expect(c).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should count with where clause', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
await repo.create({
|
||||
email: 'countfilter@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const c = await repo.count({ email: 'countfilter@test.com' });
|
||||
expect(c).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exists', () => {
|
||||
it('should return true when record exists', async () => {
|
||||
const hash = await bcrypt.hash('pass', 10);
|
||||
await repo.create({
|
||||
email: 'exists@test.com',
|
||||
password_digest: hash,
|
||||
});
|
||||
const result = await repo.exists({ email: 'exists@test.com' });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when record does not exist', async () => {
|
||||
const result = await repo.exists({
|
||||
email: 'doesnotexist@test.com',
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
166
backend/tests/unit/shared/errorHandler.test.js
Normal file
166
backend/tests/unit/shared/errorHandler.test.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
const errorHandler = require('../../../shared/middleware/errorHandler');
|
||||
const {
|
||||
AppError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
ConflictError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
} = require('../../../shared/errors');
|
||||
|
||||
describe('errorHandler middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {};
|
||||
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
// --- AppError subclasses ---
|
||||
|
||||
it('should handle NotFoundError with 404', () => {
|
||||
const err = new NotFoundError('Task not found');
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Task not found',
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ValidationError with 400', () => {
|
||||
const err = new ValidationError('Title is required');
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Title is required',
|
||||
code: 'VALIDATION_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ConflictError with 409', () => {
|
||||
const err = new ConflictError();
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(409);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Resource already exists',
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle UnauthorizedError with 401', () => {
|
||||
const err = new UnauthorizedError();
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ForbiddenError with 403', () => {
|
||||
const err = new ForbiddenError();
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Forbidden',
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle generic AppError with custom status', () => {
|
||||
const err = new AppError('Rate limited', 429, 'RATE_LIMITED');
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Rate limited',
|
||||
code: 'RATE_LIMITED',
|
||||
});
|
||||
});
|
||||
|
||||
// --- Sequelize errors ---
|
||||
|
||||
it('should handle SequelizeValidationError with 400', () => {
|
||||
const err = {
|
||||
name: 'SequelizeValidationError',
|
||||
errors: [
|
||||
{ message: 'email must be unique' },
|
||||
{ message: 'name cannot be null' },
|
||||
],
|
||||
};
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'email must be unique, name cannot be null',
|
||||
code: 'VALIDATION_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle SequelizeUniqueConstraintError with 409', () => {
|
||||
const err = { name: 'SequelizeUniqueConstraintError' };
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(409);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'A resource with this identifier already exists.',
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
});
|
||||
|
||||
// --- Unknown errors ---
|
||||
|
||||
it('should handle unknown errors with 500 in non-production', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const err = new Error('Something unexpected');
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Something unexpected',
|
||||
code: 'INTERNAL_ERROR',
|
||||
});
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it('should hide error message in production', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const err = new Error('Database credentials leaked');
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Internal server error',
|
||||
code: 'INTERNAL_ERROR',
|
||||
});
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it('should use statusCode from unknown error if present', () => {
|
||||
const err = new Error('Bad gateway');
|
||||
err.statusCode = 502;
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(502);
|
||||
});
|
||||
|
||||
it('should default to 500 when no statusCode on unknown error', () => {
|
||||
const err = new Error('Oops');
|
||||
errorHandler(err, req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
360
backend/tests/unit/utils/migration-utils.test.js
Normal file
360
backend/tests/unit/utils/migration-utils.test.js
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
const {
|
||||
safeAddColumns,
|
||||
safeCreateTable,
|
||||
safeAddIndex,
|
||||
safeRemoveColumn,
|
||||
safeChangeColumn,
|
||||
} = require('../../../utils/migration-utils');
|
||||
|
||||
describe('migration-utils', () => {
|
||||
let queryInterface;
|
||||
|
||||
beforeEach(() => {
|
||||
queryInterface = {
|
||||
showAllTables: jest.fn(),
|
||||
describeTable: jest.fn(),
|
||||
addColumn: jest.fn(),
|
||||
removeColumn: jest.fn(),
|
||||
createTable: jest.fn(),
|
||||
showIndex: jest.fn(),
|
||||
addIndex: jest.fn(),
|
||||
changeColumn: jest.fn(),
|
||||
sequelize: {
|
||||
getDialect: jest.fn().mockReturnValue('postgres'),
|
||||
query: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('safeAddColumns', () => {
|
||||
it('should skip when table does not exist', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['other_table']);
|
||||
await safeAddColumns(queryInterface, 'missing_table', [
|
||||
{ name: 'col1', definition: { type: 'TEXT' } },
|
||||
]);
|
||||
expect(queryInterface.addColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add column when it does not exist', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {},
|
||||
title: {},
|
||||
});
|
||||
const definition = { type: 'TEXT' };
|
||||
|
||||
await safeAddColumns(queryInterface, 'tasks', [
|
||||
{ name: 'description', definition },
|
||||
]);
|
||||
|
||||
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
||||
'tasks',
|
||||
'description',
|
||||
definition
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip column when it already exists', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {},
|
||||
title: {},
|
||||
});
|
||||
|
||||
await safeAddColumns(queryInterface, 'tasks', [
|
||||
{ name: 'title', definition: { type: 'TEXT' } },
|
||||
]);
|
||||
|
||||
expect(queryInterface.addColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add multiple columns, skipping existing ones', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {},
|
||||
title: {},
|
||||
});
|
||||
|
||||
await safeAddColumns(queryInterface, 'tasks', [
|
||||
{ name: 'title', definition: { type: 'TEXT' } }, // exists
|
||||
{ name: 'priority', definition: { type: 'INTEGER' } }, // new
|
||||
{ name: 'due_date', definition: { type: 'DATE' } }, // new
|
||||
]);
|
||||
|
||||
expect(queryInterface.addColumn).toHaveBeenCalledTimes(2);
|
||||
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
||||
'tasks',
|
||||
'priority',
|
||||
{ type: 'INTEGER' }
|
||||
);
|
||||
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
||||
'tasks',
|
||||
'due_date',
|
||||
{ type: 'DATE' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should re-throw errors from addColumn', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({ id: {} });
|
||||
queryInterface.addColumn.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
await expect(
|
||||
safeAddColumns(queryInterface, 'tasks', [
|
||||
{ name: 'col', definition: { type: 'TEXT' } },
|
||||
])
|
||||
).rejects.toThrow('DB error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeCreateTable', () => {
|
||||
it('should create table when it does not exist', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue([]);
|
||||
const definition = { id: { type: 'INTEGER', primaryKey: true } };
|
||||
|
||||
await safeCreateTable(queryInterface, 'new_table', definition);
|
||||
|
||||
expect(queryInterface.createTable).toHaveBeenCalledWith(
|
||||
'new_table',
|
||||
definition
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip when table already exists', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['existing_table']);
|
||||
|
||||
await safeCreateTable(queryInterface, 'existing_table', {});
|
||||
|
||||
expect(queryInterface.createTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should re-throw errors from createTable', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue([]);
|
||||
queryInterface.createTable.mockRejectedValue(
|
||||
new Error('Create failed')
|
||||
);
|
||||
|
||||
await expect(
|
||||
safeCreateTable(queryInterface, 'fail_table', {})
|
||||
).rejects.toThrow('Create failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeAddIndex', () => {
|
||||
it('should skip when table does not exist', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue([]);
|
||||
await safeAddIndex(queryInterface, 'missing_table', ['col1']);
|
||||
expect(queryInterface.addIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add index when it does not exist', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.showIndex.mockResolvedValue([]);
|
||||
|
||||
await safeAddIndex(queryInterface, 'tasks', ['user_id'], {
|
||||
name: 'idx_user',
|
||||
});
|
||||
|
||||
expect(queryInterface.addIndex).toHaveBeenCalledWith(
|
||||
'tasks',
|
||||
['user_id'],
|
||||
{ name: 'idx_user' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip when a matching index already exists', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.showIndex.mockResolvedValue([
|
||||
{ fields: [{ attribute: 'user_id' }] },
|
||||
]);
|
||||
|
||||
await safeAddIndex(queryInterface, 'tasks', ['user_id']);
|
||||
|
||||
expect(queryInterface.addIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw on addIndex failure (swallows error)', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.showIndex.mockResolvedValue([]);
|
||||
queryInterface.addIndex.mockRejectedValue(new Error('Index error'));
|
||||
|
||||
// safeAddIndex swallows errors (no throw in the catch)
|
||||
await expect(
|
||||
safeAddIndex(queryInterface, 'tasks', ['col'])
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeRemoveColumn', () => {
|
||||
it('should skip when column does not exist', async () => {
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {},
|
||||
title: {},
|
||||
});
|
||||
|
||||
await safeRemoveColumn(queryInterface, 'tasks', 'nonexistent');
|
||||
|
||||
expect(queryInterface.removeColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove column on non-SQLite dialect', async () => {
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {},
|
||||
title: {},
|
||||
old_col: {},
|
||||
});
|
||||
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
||||
|
||||
await safeRemoveColumn(queryInterface, 'tasks', 'old_col');
|
||||
|
||||
expect(queryInterface.removeColumn).toHaveBeenCalledWith(
|
||||
'tasks',
|
||||
'old_col'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use table recreation strategy on SQLite', async () => {
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {
|
||||
type: 'INTEGER',
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
title: { type: 'TEXT', allowNull: true },
|
||||
remove_me: { type: 'TEXT', allowNull: true },
|
||||
});
|
||||
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
|
||||
|
||||
await safeRemoveColumn(queryInterface, 'tasks', 'remove_me');
|
||||
|
||||
// Should have called multiple queries for SQLite recreation
|
||||
const calls = queryInterface.sequelize.query.mock.calls.map(
|
||||
(c) => c[0]
|
||||
);
|
||||
expect(
|
||||
calls.some((q) => q.includes('PRAGMA foreign_keys = OFF'))
|
||||
).toBe(true);
|
||||
expect(
|
||||
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
|
||||
).toBe(true);
|
||||
expect(calls.some((q) => q.includes('INSERT INTO tasks_new'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(calls.some((q) => q.includes('DROP TABLE tasks'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
calls.some((q) =>
|
||||
q.includes('ALTER TABLE tasks_new RENAME TO tasks')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
calls.some((q) => q.includes('PRAGMA foreign_keys = ON'))
|
||||
).toBe(true);
|
||||
// remove_me should not appear in CREATE TABLE
|
||||
const createCall = calls.find((q) =>
|
||||
q.includes('CREATE TABLE tasks_new')
|
||||
);
|
||||
expect(createCall).not.toContain('remove_me');
|
||||
});
|
||||
|
||||
it('should re-throw errors', async () => {
|
||||
queryInterface.describeTable.mockRejectedValue(
|
||||
new Error('Describe failed')
|
||||
);
|
||||
|
||||
await expect(
|
||||
safeRemoveColumn(queryInterface, 'tasks', 'col')
|
||||
).rejects.toThrow('Describe failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeChangeColumn', () => {
|
||||
it('should skip when table does not exist', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue([]);
|
||||
|
||||
await safeChangeColumn(queryInterface, 'missing', 'col', {
|
||||
type: 'TEXT',
|
||||
});
|
||||
|
||||
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when column does not exist', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({ id: {} });
|
||||
|
||||
await safeChangeColumn(queryInterface, 'tasks', 'nonexistent', {
|
||||
type: 'TEXT',
|
||||
});
|
||||
|
||||
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should change column on non-SQLite dialect', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {},
|
||||
title: { type: 'TEXT', allowNull: true },
|
||||
});
|
||||
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
||||
const newDef = { type: 'VARCHAR(255)', allowNull: false };
|
||||
|
||||
await safeChangeColumn(queryInterface, 'tasks', 'title', newDef);
|
||||
|
||||
expect(queryInterface.changeColumn).toHaveBeenCalledWith(
|
||||
'tasks',
|
||||
'title',
|
||||
newDef
|
||||
);
|
||||
});
|
||||
|
||||
it('should use table recreation on SQLite', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({
|
||||
id: {
|
||||
type: 'INTEGER',
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
title: { type: 'TEXT', allowNull: true },
|
||||
});
|
||||
queryInterface.showIndex.mockResolvedValue([]);
|
||||
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
|
||||
|
||||
await safeChangeColumn(queryInterface, 'tasks', 'title', {
|
||||
type: { toSql: () => 'VARCHAR(255)' },
|
||||
allowNull: false,
|
||||
});
|
||||
|
||||
const calls = queryInterface.sequelize.query.mock.calls.map(
|
||||
(c) => c[0]
|
||||
);
|
||||
expect(
|
||||
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
|
||||
).toBe(true);
|
||||
const createCall = calls.find((q) =>
|
||||
q.includes('CREATE TABLE tasks_new')
|
||||
);
|
||||
expect(createCall).toContain('VARCHAR(255)');
|
||||
expect(createCall).toContain('NOT NULL');
|
||||
});
|
||||
|
||||
it('should re-throw errors', async () => {
|
||||
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||
queryInterface.describeTable.mockResolvedValue({ id: {}, col: {} });
|
||||
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
||||
queryInterface.changeColumn.mockRejectedValue(
|
||||
new Error('Change failed')
|
||||
);
|
||||
|
||||
await expect(
|
||||
safeChangeColumn(queryInterface, 'tasks', 'col', {
|
||||
type: 'TEXT',
|
||||
})
|
||||
).rejects.toThrow('Change failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
29
backend/tests/unit/utils/uid.test.js
Normal file
29
backend/tests/unit/utils/uid.test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const { uid } = require('../../../utils/uid');
|
||||
|
||||
describe('uid utility', () => {
|
||||
it('should return a string', () => {
|
||||
const id = uid();
|
||||
expect(typeof id).toBe('string');
|
||||
});
|
||||
|
||||
it('should return a 15-character string', () => {
|
||||
const id = uid();
|
||||
expect(id).toHaveLength(15);
|
||||
});
|
||||
|
||||
it('should generate different ids on successive calls', () => {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ids.add(uid());
|
||||
}
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
|
||||
it('should only contain characters from the allowed alphabet', () => {
|
||||
// The allowed alphabet is '0123456789abcdefghijkmnpqrstuvwxyz'
|
||||
// (no 'o' or 'l' to avoid ambiguity)
|
||||
// Note: in test env nanoid is mocked, so this validates the mock contract
|
||||
const id = uid();
|
||||
expect(id).toMatch(/^[0-9a-z]+$/);
|
||||
});
|
||||
});
|
||||
350
docs/architecture.md
Normal file
350
docs/architecture.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# Architecture Overview
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frontend Stack
|
||||
|
||||
- **Framework:** React 18.3.1
|
||||
- **Language:** TypeScript 5.6.2
|
||||
- **Build Tool:** Webpack 5 with hot module replacement
|
||||
- **Styling:** Tailwind CSS 3.4.13 + Heroicons
|
||||
- **State Management:** Zustand 5.0.3 (global state), SWR 2.2.5 (server state)
|
||||
- **Routing:** React Router DOM 6.26.2
|
||||
- **Internationalization:** i18next + react-i18next (24 languages)
|
||||
- **Charts/Analytics:** Recharts 2.15.4
|
||||
- **Drag & Drop:** @dnd-kit (sortable tasks)
|
||||
- **Development:** webpack-dev-server with proxy configuration
|
||||
|
||||
### Backend Stack
|
||||
|
||||
- **Framework:** Express.js 4.21.2
|
||||
- **ORM:** Sequelize 6.37.7
|
||||
- **Database:** SQLite 5.1.7 (WAL mode, memory-mapped I/O for performance)
|
||||
- **Authentication:** bcrypt + express-session + connect-session-sequelize
|
||||
- **Security:** Helmet, CORS, express-rate-limit
|
||||
- **API Documentation:** Swagger (swagger-jsdoc + swagger-ui-express)
|
||||
- **File Upload:** Multer
|
||||
- **Email:** Nodemailer 7.0.10
|
||||
- **Scheduling:** node-cron 4.1.0 (for recurring tasks)
|
||||
- **Date/Time:** date-fns 4.1.0 + date-fns-tz
|
||||
|
||||
### Testing Stack
|
||||
|
||||
- **Backend:** Jest + Supertest + supertest-session
|
||||
- **Frontend:** Jest + React Testing Library
|
||||
- **E2E:** Playwright
|
||||
- **Linting:** ESLint 8 + Prettier 3.6.2
|
||||
|
||||
---
|
||||
|
||||
## Request Flow
|
||||
|
||||
### Development Mode
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Development Flow │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Browser (http://localhost:8080) │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ Webpack Dev Server │ │
|
||||
│ │ Port: 8080 │ │
|
||||
│ │ - Hot reload │ │
|
||||
│ │ - Proxy /api/* │ │
|
||||
│ │ - Proxy /locales/*│ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ │ Proxies requests to backend │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ Express Server │ │
|
||||
│ │ Port: 3002 │ │
|
||||
│ │ - API endpoints │ │
|
||||
│ │ - Session auth │ │
|
||||
│ │ - Rate limiting │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ Sequelize ORM │ │
|
||||
│ │ - Model layer │ │
|
||||
│ │ - Relationships │ │
|
||||
│ │ - Migrations │ │
|
||||
│ └──────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ SQLite Database │ │
|
||||
│ │ (database.sqlite) │ │
|
||||
│ │ - WAL mode │ │
|
||||
│ │ - Optimized I/O │ │
|
||||
│ └─────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Production Mode
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Production Flow │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Browser → Express Server (Port 3002) │
|
||||
│ ↓ │
|
||||
│ ├─ Serves static files from /dist │
|
||||
│ │ (compiled React app) │
|
||||
│ │ │
|
||||
│ └─ /api routes → Sequelize → SQLite │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Model Hierarchy
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ User │
|
||||
│ (Authentication, Settings, Timezone) │
|
||||
└───────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
│ 1:N (One user has many areas)
|
||||
↓
|
||||
┌───────────────────────┐
|
||||
│ Area │ (Optional organizational layer)
|
||||
│ (e.g., Work, │
|
||||
│ Personal, Health) │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
│ 1:N (One area has many projects)
|
||||
↓
|
||||
┌───────────────────────┐ ┌──────────┐
|
||||
│ Project │────────────────│ Tag │
|
||||
│ (Website Redesign, │ N:M │ (urgent,│
|
||||
│ Marketing Campaign) │ │ design, │
|
||||
└──────────┬────────────┘ │ bug) │
|
||||
│ └─────┬────┘
|
||||
│ 1:N (One project has │
|
||||
│ many tasks) │ N:M
|
||||
↓ │
|
||||
┌───────────────────────┐ │
|
||||
│ Task │────────────────────┘
|
||||
│ (Design Homepage, │ N:M (Tasks can
|
||||
│ Write Blog Post) │ have tags)
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
│ 1:N (Task can have subtasks)
|
||||
↓
|
||||
┌───────────────────────┐
|
||||
│ Subtask │ (Self-referential)
|
||||
│ (Create mockup, │
|
||||
│ Get feedback) │
|
||||
└───────────────────────┘
|
||||
|
||||
|
||||
Additional Relationships:
|
||||
|
||||
Project 1:N Note (One project has many notes)
|
||||
Note N:M Tag (Notes can have tags)
|
||||
|
||||
Task → RecurringCompletion (Tracks completion history)
|
||||
Task → TaskEvent (Audit log of all changes)
|
||||
Task → TaskAttachment (File attachments)
|
||||
|
||||
Special Task Relationships:
|
||||
- parent_task_id: Links subtasks to parent task
|
||||
- recurring_parent_id: Links recurring instances to original pattern
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
### Dual Authentication Methods
|
||||
|
||||
**1. Session-Based (Primary - Web Interface)**
|
||||
|
||||
- Express session with Sequelize store
|
||||
- HttpOnly cookies (30-day expiration)
|
||||
- Session data stored in database table
|
||||
- SameSite: 'lax' for CSRF protection
|
||||
- Implementation: `/backend/middleware/auth.js`
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
1. User logs in with email/password
|
||||
2. bcrypt verifies password hash
|
||||
3. Session created and stored in database
|
||||
4. Session cookie sent to browser
|
||||
5. Subsequent requests include cookie
|
||||
6. Middleware validates session and attaches user
|
||||
```
|
||||
|
||||
**2. Bearer Token (API Access)**
|
||||
|
||||
- Personal API tokens for automation/integrations
|
||||
- Format: `Authorization: Bearer YOUR_TOKEN`
|
||||
- Token prefix: `tt_` + 64 hex characters
|
||||
- Stored as bcrypt hash in database
|
||||
- Optional expiration and abilities (JSON)
|
||||
- Generated via web UI: Profile > API Tokens
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
1. User generates token in web interface
|
||||
2. Token hash stored with prefix (first 12 chars)
|
||||
3. API requests include: Authorization: Bearer tt_xxx...
|
||||
4. Middleware validates token hash
|
||||
5. Updates last_used_at timestamp
|
||||
6. Attaches user to request
|
||||
```
|
||||
|
||||
### Permission System
|
||||
|
||||
**Permission Levels:**
|
||||
- `NONE` (0) - No access
|
||||
- `RO` (1) - Read-only access
|
||||
- `RW` (2) - Read-write access
|
||||
- `ADMIN` (3) - Full administrative access
|
||||
|
||||
**Access Rules:**
|
||||
|
||||
1. **Ownership** → Automatic RW access
|
||||
- User has RW for resources they created
|
||||
|
||||
2. **Project Sharing** → Granted via Permission model
|
||||
- Permission record grants RO/RW/ADMIN to specific user
|
||||
- resource_type: 'project', 'task', 'note'
|
||||
- resource_uid: unique identifier
|
||||
|
||||
3. **Inheritance**
|
||||
- Tasks inherit access from parent Project
|
||||
- Notes inherit access from parent Project
|
||||
- Subtasks inherit access from parent Task
|
||||
|
||||
**Authorization Middleware:**
|
||||
```javascript
|
||||
// Location: /backend/middleware/authorize.js
|
||||
|
||||
hasAccess(requiredAccess, resourceType, getResourceUid, options)
|
||||
|
||||
// Usage in routes:
|
||||
router.get('/task/:id',
|
||||
hasAccess('ro', 'task', (req) => req.params.id),
|
||||
async (req, res) => { ... }
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Modules Overview
|
||||
|
||||
### Backend Modules (19 total)
|
||||
|
||||
Located in `/backend/modules/`, each follows consistent architecture:
|
||||
|
||||
| Module | Purpose | Complexity |
|
||||
|--------|---------|------------|
|
||||
| **tasks** | Task management, subtasks, recurring | High - most complex module |
|
||||
| **projects** | Project CRUD and organization | Medium |
|
||||
| **areas** | Area categorization | Low |
|
||||
| **notes** | Note-taking system | Medium |
|
||||
| **tags** | Tagging system | Low |
|
||||
| **users** | User management | Medium |
|
||||
| **auth** | Authentication (login/register) | Medium |
|
||||
| **shares** | Project sharing & permissions | High |
|
||||
| **telegram** | Telegram bot integration | Medium |
|
||||
| **inbox** | Quick capture inbox | Low |
|
||||
| **habits** | Habit tracking | Medium |
|
||||
| **notifications** | In-app notifications | Medium |
|
||||
| **search** | Universal search | Medium |
|
||||
| **views** | Saved custom views | Low |
|
||||
| **admin** | Admin operations | Low |
|
||||
| **backup** | Backup/restore functionality | High |
|
||||
| **feature-flags** | Feature flag management | Low |
|
||||
| **quotes** | Daily quotes | Low |
|
||||
| **url** | URL handling | Low |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Highlights
|
||||
|
||||
**20+ Sequelize Models** in `/backend/models/`
|
||||
|
||||
**Core Tables:**
|
||||
- **Users** - Authentication, settings, preferences
|
||||
- **Areas** - Organizational categories
|
||||
- **Projects** - Project grouping
|
||||
- **Tasks** - Main task entity (11 indexes for performance)
|
||||
- **Notes** - Project notes
|
||||
- **Tags** - Flexible tagging
|
||||
- **Permissions** - Sharing and access control
|
||||
- **ApiTokens** - API authentication
|
||||
- **RecurringCompletions** - Recurring task history
|
||||
- **TaskEvents** - Audit log
|
||||
- **TaskAttachments** - File uploads
|
||||
- **InboxItems** - Quick capture
|
||||
- **Notifications** - User notifications
|
||||
- **Roles** - User role system
|
||||
- **Views** - Saved custom views
|
||||
- **Backups** - Backup records
|
||||
- **Settings** - Application config
|
||||
- **Actions** - Audit trail
|
||||
|
||||
**Junction Tables (Many-to-Many):**
|
||||
- tasks_tags
|
||||
- notes_tags
|
||||
- projects_tags
|
||||
|
||||
**Special Fields in Tasks:**
|
||||
- `recurrence_type`: daily, weekly, monthly, monthly_weekday, monthly_last_day
|
||||
- `recurrence_interval`: Custom intervals (every 2 weeks, etc.)
|
||||
- `parent_task_id`: Links subtasks to parent (self-referential)
|
||||
- `recurring_parent_id`: Links recurring instances to original pattern
|
||||
|
||||
**Performance Optimizations:**
|
||||
- WAL (Write-Ahead Logging) mode
|
||||
- PRAGMA synchronous=NORMAL
|
||||
- 64MB cache size
|
||||
- 256MB memory-mapped I/O
|
||||
- Memory-based temp storage
|
||||
- 11 indexes on Tasks table for slow I/O systems
|
||||
|
||||
---
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
**Component Organization:**
|
||||
- Feature-based structure in `/frontend/components/`
|
||||
- Shared components in `/frontend/components/Shared/`
|
||||
- Each feature has dedicated directory (Task/, Project/, Area/, etc.)
|
||||
|
||||
**State Management:**
|
||||
- **Global State:** Zustand store (`/frontend/store/useStore.ts`)
|
||||
- Task cache
|
||||
- Project cache
|
||||
- UI state (modals, filters, selections)
|
||||
- **Server State:** SWR for data fetching
|
||||
- Automatic revalidation
|
||||
- Optimistic updates
|
||||
- Cache management
|
||||
- **Local State:** React useState for component-specific data
|
||||
|
||||
**Key Frontend Patterns:**
|
||||
- Functional components with hooks (no class components)
|
||||
- TypeScript interfaces in `/frontend/entities/`
|
||||
- API services in `/frontend/utils/[resource]Service.ts`
|
||||
- Custom hooks in `/frontend/hooks/`
|
||||
- Context providers in `/frontend/contexts/`
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
649
docs/backend-patterns.md
Normal file
649
docs/backend-patterns.md
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
# Backend Module Architecture
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Standard Module Structure
|
||||
|
||||
All feature modules in `/backend/modules/` follow a consistent architecture pattern. This ensures code is organized, maintainable, and easy to navigate.
|
||||
|
||||
### Typical Module Directory
|
||||
|
||||
```
|
||||
/backend/modules/[module-name]/
|
||||
├── routes.js # Express router with endpoint definitions
|
||||
├── repository.js # Data access layer (Sequelize queries)
|
||||
├── operations/ # Business logic operations (optional)
|
||||
│ ├── create.js
|
||||
│ ├── update.js
|
||||
│ ├── delete.js
|
||||
│ └── ...
|
||||
├── queries/ # Complex query builders (optional)
|
||||
├── core/ # Core utilities (optional)
|
||||
│ ├── serializers.js # Format data for API responses
|
||||
│ ├── parsers.js # Parse request data
|
||||
│ └── builders.js # Build database objects
|
||||
├── middleware/ # Route-specific middleware (optional)
|
||||
│ └── access.js # Access control
|
||||
└── utils/ # Module-specific utilities
|
||||
└── validation.js # Input validation
|
||||
```
|
||||
|
||||
**Note:** Not all modules have all directories. Simpler modules may only have `routes.js` and basic logic.
|
||||
|
||||
---
|
||||
|
||||
## Example: Tasks Module (Complex)
|
||||
|
||||
The tasks module (`/backend/modules/tasks/`) is the most comprehensive example, showing the full potential of the module pattern:
|
||||
|
||||
```
|
||||
/backend/modules/tasks/
|
||||
├── routes.js # Main routes: GET /tasks, POST /task, etc.
|
||||
├── repository.js # Data access: findTaskById, findTasksForUser
|
||||
├── recurringTaskService.js # Recurring task pattern logic (8.5KB)
|
||||
├── taskEventService.js # Task activity logging
|
||||
├── taskScheduler.js # Cron-based task scheduling with node-cron
|
||||
│
|
||||
├── operations/ # Business logic operations
|
||||
│ ├── list.js # List operations and filtering
|
||||
│ ├── completion.js # Task completion/status changes
|
||||
│ ├── recurring.js # Recurrence pattern handling
|
||||
│ ├── subtasks.js # Subtask CRUD operations
|
||||
│ ├── tags.js # Tag assignment to tasks
|
||||
│ ├── grouping.js # Task grouping logic
|
||||
│ ├── sorting.js # Sort order logic
|
||||
│ └── parent-child.js # Parent-child relationship handling
|
||||
│
|
||||
├── queries/
|
||||
│ ├── query-builders.js # filterTasksByParams, buildWhereClause
|
||||
│ ├── metrics-queries.js # Task metrics and analytics queries
|
||||
│ └── metrics-computation.js # Metric calculations and aggregations
|
||||
│
|
||||
├── core/
|
||||
│ ├── serializers.js # serializeTask, serializeTasks
|
||||
│ ├── builders.js # buildTaskAttributes for create/update
|
||||
│ ├── parsers.js # parseTaskInput from requests
|
||||
│ └── comparators.js # Detect task changes for audit log
|
||||
│
|
||||
├── middleware/
|
||||
│ └── access.js # Task-specific access control
|
||||
│
|
||||
└── utils/
|
||||
├── constants.js # Task-specific constants (status codes, etc.)
|
||||
├── validation.js # Task input validation rules
|
||||
└── logging.js # Change tracking helpers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example: Projects Module (Simpler)
|
||||
|
||||
The projects module (`/backend/modules/projects/`) follows the same pattern but with less complexity:
|
||||
|
||||
```
|
||||
/backend/modules/projects/
|
||||
├── routes.js # Project CRUD endpoints
|
||||
├── repository.js # Project data access (findAll, findById, create, etc.)
|
||||
└── utils/
|
||||
└── validation.js # Project validation (name required, etc.)
|
||||
```
|
||||
|
||||
This shows that modules scale based on complexity - simple features don't require the full directory structure.
|
||||
|
||||
---
|
||||
|
||||
## Module Pattern Details
|
||||
|
||||
### routes.js - Express Router
|
||||
|
||||
**Purpose:** Define HTTP endpoints for the module
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// /backend/modules/[module]/routes.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const repository = require('./repository');
|
||||
const { hasAccess } = require('../../middleware/authorize');
|
||||
|
||||
// List all resources (collection)
|
||||
router.get('/[resources]', async (req, res, next) => {
|
||||
try {
|
||||
const items = await repository.findAll(req.currentUser.id, req.query);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
next(error); // Pass to global error handler
|
||||
}
|
||||
});
|
||||
|
||||
// Create new resource (singular)
|
||||
router.post('/[resource]', async (req, res, next) => {
|
||||
try {
|
||||
const item = await repository.create(req.body, req.currentUser.id);
|
||||
res.status(201).json(item);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single resource (with authorization)
|
||||
router.get('/[resource]/:id',
|
||||
hasAccess('ro', '[resource]', (req) => req.params.id),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const item = await repository.findById(req.params.id, req.currentUser.id);
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update resource (with authorization)
|
||||
router.put('/[resource]/:id',
|
||||
hasAccess('rw', '[resource]', (req) => req.params.id),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const updated = await repository.update(req.params.id, req.body, req.currentUser.id);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete resource
|
||||
router.delete('/[resource]/:id',
|
||||
hasAccess('rw', '[resource]', (req) => req.params.id),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await repository.destroy(req.params.id, req.currentUser.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
**Key Conventions:**
|
||||
- Plural for collections: `GET /tasks`
|
||||
- Singular for single resource: `GET /task/:id`, `POST /task`
|
||||
- Always use try/catch
|
||||
- Pass errors to `next(error)` for global error handler
|
||||
- Use `hasAccess()` middleware for authorization
|
||||
- Return proper HTTP status codes (200, 201, 204, 404, etc.)
|
||||
|
||||
---
|
||||
|
||||
### repository.js - Data Access Layer
|
||||
|
||||
**Purpose:** Abstract database queries from routes
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// /backend/modules/[module]/repository.js
|
||||
const { Model } = require('../../models');
|
||||
|
||||
async function findAll(userId, filters = {}) {
|
||||
return await Model.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
...buildWhereClause(filters)
|
||||
},
|
||||
include: [...],
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
}
|
||||
|
||||
async function findById(id, userId) {
|
||||
return await Model.findOne({
|
||||
where: { id, user_id: userId },
|
||||
include: [...]
|
||||
});
|
||||
}
|
||||
|
||||
async function create(data, userId) {
|
||||
return await Model.create({
|
||||
...data,
|
||||
user_id: userId
|
||||
});
|
||||
}
|
||||
|
||||
async function update(id, data, userId) {
|
||||
const instance = await findById(id, userId);
|
||||
if (!instance) {
|
||||
throw new NotFoundError('Resource not found');
|
||||
}
|
||||
return await instance.update(data);
|
||||
}
|
||||
|
||||
async function destroy(id, userId) {
|
||||
const instance = await findById(id, userId);
|
||||
if (!instance) {
|
||||
throw new NotFoundError('Resource not found');
|
||||
}
|
||||
await instance.destroy();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findAll,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
destroy
|
||||
};
|
||||
```
|
||||
|
||||
**Why Repository Pattern:**
|
||||
- Separates data access from business logic
|
||||
- Makes testing easier (can mock repository)
|
||||
- Centralizes query logic
|
||||
- Prevents Model usage directly in routes
|
||||
|
||||
---
|
||||
|
||||
### core/serializers.js - Response Formatting
|
||||
|
||||
**Purpose:** Transform database objects into API-friendly format
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// /backend/modules/[module]/core/serializers.js
|
||||
|
||||
function serializeItem(item) {
|
||||
if (!item) return null;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
uid: item.uid,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
// Include associations if loaded
|
||||
tags: item.Tags ? item.Tags.map(serializeTag) : undefined,
|
||||
user: item.User ? serializeUser(item.User) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function serializeItems(items) {
|
||||
return items.map(serializeItem);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeItem,
|
||||
serializeItems
|
||||
};
|
||||
```
|
||||
|
||||
**Usage in routes:**
|
||||
```javascript
|
||||
const { serializeItem, serializeItems } = require('./core/serializers');
|
||||
|
||||
router.get('/items', async (req, res) => {
|
||||
const items = await repository.findAll(req.currentUser.id);
|
||||
res.json(serializeItems(items)); // Transform before sending
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### core/builders.js - Object Construction
|
||||
|
||||
**Purpose:** Build objects for database creation/update from request data
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
// /backend/modules/[module]/core/builders.js
|
||||
|
||||
function buildItemAttributes(data, userId) {
|
||||
const attributes = {
|
||||
user_id: userId,
|
||||
name: data.name?.trim(),
|
||||
description: data.description?.trim() || null
|
||||
};
|
||||
|
||||
// Optional fields
|
||||
if (data.due_date) {
|
||||
attributes.due_date = parseDate(data.due_date);
|
||||
}
|
||||
|
||||
if (data.priority !== undefined) {
|
||||
attributes.priority = parseInt(data.priority, 10);
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
module.exports = { buildItemAttributes };
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```javascript
|
||||
const { buildItemAttributes } = require('./core/builders');
|
||||
|
||||
router.post('/item', async (req, res) => {
|
||||
const attributes = buildItemAttributes(req.body, req.currentUser.id);
|
||||
const item = await repository.create(attributes);
|
||||
res.status(201).json(serializeItem(item));
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### operations/ - Business Logic
|
||||
|
||||
**Purpose:** Complex operations that don't fit in simple CRUD
|
||||
|
||||
**Example: operations/completion.js (from tasks module)**
|
||||
```javascript
|
||||
// /backend/modules/tasks/operations/completion.js
|
||||
|
||||
async function completeTask(taskId, userId, completionData) {
|
||||
// Get task with associations
|
||||
const task = await repository.findById(taskId, userId);
|
||||
|
||||
if (task.recurrence_type) {
|
||||
// Handle recurring task completion
|
||||
await recurringTaskService.handleCompletion(task, completionData);
|
||||
} else {
|
||||
// Simple completion
|
||||
task.status = 2; // completed
|
||||
task.completed_at = new Date();
|
||||
await task.save();
|
||||
}
|
||||
|
||||
// Log event
|
||||
await taskEventService.logEvent(taskId, 'completed', userId);
|
||||
|
||||
// Update related subtasks
|
||||
if (completionData.completeSubtasks) {
|
||||
await completeSubtasks(taskId);
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
module.exports = { completeTask };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Module Communication
|
||||
|
||||
### Accessing Other Modules
|
||||
|
||||
Modules can import other module repositories and services:
|
||||
|
||||
```javascript
|
||||
// In /backend/modules/projects/routes.js
|
||||
|
||||
// Import task repository from tasks module
|
||||
const taskRepository = require('../tasks/repository');
|
||||
|
||||
// Import shared service
|
||||
const permissionsService = require('../../services/permissionsService');
|
||||
|
||||
router.get('/project/:id/tasks', async (req, res) => {
|
||||
// Use task repository
|
||||
const tasks = await taskRepository.findTasksForProject(req.params.id);
|
||||
res.json(tasks);
|
||||
});
|
||||
```
|
||||
|
||||
### Avoid Circular Dependencies
|
||||
|
||||
**Bad:**
|
||||
```javascript
|
||||
// Module A imports Module B
|
||||
const moduleB = require('../moduleB');
|
||||
|
||||
// Module B imports Module A
|
||||
const moduleA = require('../moduleA'); // CIRCULAR!
|
||||
```
|
||||
|
||||
**Good:**
|
||||
```javascript
|
||||
// Extract shared logic to /backend/services/
|
||||
// Both modules import from services
|
||||
const sharedService = require('../../services/sharedService');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Add a New Module
|
||||
|
||||
**Example: Creating a "labels" module**
|
||||
|
||||
### Step 1: Create Directory Structure
|
||||
|
||||
```bash
|
||||
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels
|
||||
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels/utils
|
||||
```
|
||||
|
||||
### Step 2: Create routes.js
|
||||
|
||||
```javascript
|
||||
// /backend/modules/labels/routes.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const repository = require('./repository');
|
||||
|
||||
router.get('/labels', async (req, res, next) => {
|
||||
try {
|
||||
const labels = await repository.findAll(req.currentUser.id);
|
||||
res.json(labels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/label', async (req, res, next) => {
|
||||
try {
|
||||
const label = await repository.create(req.body, req.currentUser.id);
|
||||
res.status(201).json(label);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/label/:id', async (req, res, next) => {
|
||||
try {
|
||||
const label = await repository.update(req.params.id, req.body, req.currentUser.id);
|
||||
res.json(label);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/label/:id', async (req, res, next) => {
|
||||
try {
|
||||
await repository.destroy(req.params.id, req.currentUser.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
### Step 3: Create repository.js
|
||||
|
||||
```javascript
|
||||
// /backend/modules/labels/repository.js
|
||||
const { Label } = require('../../models');
|
||||
|
||||
async function findAll(userId) {
|
||||
return await Label.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
}
|
||||
|
||||
async function findById(id, userId) {
|
||||
return await Label.findOne({
|
||||
where: { id, user_id: userId }
|
||||
});
|
||||
}
|
||||
|
||||
async function create(data, userId) {
|
||||
return await Label.create({
|
||||
name: data.name,
|
||||
color: data.color || '#gray',
|
||||
user_id: userId
|
||||
});
|
||||
}
|
||||
|
||||
async function update(id, data, userId) {
|
||||
const label = await findById(id, userId);
|
||||
if (!label) {
|
||||
throw new Error('Label not found');
|
||||
}
|
||||
return await label.update({
|
||||
name: data.name,
|
||||
color: data.color
|
||||
});
|
||||
}
|
||||
|
||||
async function destroy(id, userId) {
|
||||
const label = await findById(id, userId);
|
||||
if (!label) {
|
||||
throw new Error('Label not found');
|
||||
}
|
||||
await label.destroy();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findAll,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
destroy
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: Create Model
|
||||
|
||||
See [Database documentation](database.md) for creating models and migrations.
|
||||
|
||||
### Step 5: Register Routes in app.js
|
||||
|
||||
Edit `/backend/app.js`:
|
||||
|
||||
```javascript
|
||||
// Add with other module registrations (around line 50-70)
|
||||
|
||||
// Labels module
|
||||
app.use('/api/v1', require('./modules/labels/routes'));
|
||||
app.use('/api', require('./modules/labels/routes')); // Backward compatibility
|
||||
```
|
||||
|
||||
### Step 6: Add Swagger Documentation
|
||||
|
||||
Edit `/backend/config/swagger.js` to add Label schema:
|
||||
|
||||
```javascript
|
||||
components: {
|
||||
schemas: {
|
||||
// ... existing schemas ...
|
||||
Label: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
uid: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
color: { type: 'string' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
updated_at: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Write Tests
|
||||
|
||||
Create `/backend/tests/integration/labels/labels.test.js`:
|
||||
|
||||
```javascript
|
||||
const request = require('supertest');
|
||||
const app = require('../../../app');
|
||||
const { Label, User } = require('../../../models');
|
||||
|
||||
describe('Labels API', () => {
|
||||
let user, authCookie;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/login')
|
||||
.send({ email: 'test@example.com', password: 'password123' });
|
||||
authCookie = res.headers['set-cookie'];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Label.destroy({ where: {} });
|
||||
await User.destroy({ where: {} });
|
||||
});
|
||||
|
||||
it('should create label', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/label')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ name: 'Important', color: '#red' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe('Important');
|
||||
});
|
||||
|
||||
it('should list user labels', async () => {
|
||||
await Label.create({ name: 'Label 1', user_id: user.id });
|
||||
await Label.create({ name: 'Label 2', user_id: user.id });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/v1/labels')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Step 8: Create Frontend Integration (Optional)
|
||||
|
||||
Create `/frontend/utils/labelsService.ts` and components as needed.
|
||||
|
||||
---
|
||||
|
||||
## Module Checklist
|
||||
|
||||
When adding a new module, ensure:
|
||||
|
||||
- [ ] Directory created in `/backend/modules/[name]/`
|
||||
- [ ] `routes.js` with all necessary endpoints
|
||||
- [ ] `repository.js` with data access methods
|
||||
- [ ] Model created (if new database table needed)
|
||||
- [ ] Migration created and run
|
||||
- [ ] Routes registered in `/backend/app.js`
|
||||
- [ ] Swagger schema added
|
||||
- [ ] Integration tests written
|
||||
- [ ] Documentation updated (if public feature)
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
509
docs/code-conventions.md
Normal file
509
docs/code-conventions.md
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
# Code Conventions & Patterns
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Language Usage
|
||||
|
||||
- **Frontend:** TypeScript (`.tsx`, `.ts` files)
|
||||
- **Backend:** JavaScript with optional JSDoc types (`.js` files)
|
||||
- **tsconfig.json:** Frontend only, `"strict": false`
|
||||
|
||||
---
|
||||
|
||||
## Backend Patterns
|
||||
|
||||
### 1. Async/Await (No Callbacks)
|
||||
|
||||
```javascript
|
||||
// ✅ Good - Use async/await
|
||||
async function getTasks(userId) {
|
||||
const tasks = await Task.findAll({ where: { user_id: userId } });
|
||||
return tasks;
|
||||
}
|
||||
|
||||
// ❌ Bad - No callbacks
|
||||
function getTasks(userId, callback) {
|
||||
Task.findAll({ where: { user_id: userId } })
|
||||
.then(tasks => callback(null, tasks))
|
||||
.catch(err => callback(err));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Repository Pattern
|
||||
|
||||
```javascript
|
||||
// ✅ Good - Use repository
|
||||
// In routes.js
|
||||
const repository = require('./repository');
|
||||
const task = await repository.findTaskById(req.params.id, req.currentUser.id);
|
||||
|
||||
// ❌ Bad - Don't access Model directly in routes
|
||||
const { Task } = require('../../models');
|
||||
const task = await Task.findByPk(req.params.id);
|
||||
```
|
||||
|
||||
**Why:** Repository pattern separates data access from business logic, making testing easier and centralizing query logic.
|
||||
|
||||
### 3. Error Handling
|
||||
|
||||
```javascript
|
||||
// In routes.js
|
||||
router.get('/task/:id', async (req, res, next) => {
|
||||
try {
|
||||
const task = await repository.findTaskById(req.params.id, req.currentUser.id);
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
} catch (error) {
|
||||
next(error); // Pass to global error handler
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Always:**
|
||||
- Use try/catch in route handlers
|
||||
- Pass errors to `next(error)` for global error handler
|
||||
- Return proper HTTP status codes
|
||||
- Use custom error classes from `/backend/shared/errors/`
|
||||
|
||||
### 4. Service Pattern
|
||||
|
||||
```javascript
|
||||
// Create singleton service or class
|
||||
class TaskService {
|
||||
async create(data, userId) {
|
||||
// Validation
|
||||
if (!data.name) {
|
||||
throw new ValidationError('Task name is required');
|
||||
}
|
||||
|
||||
// Business logic
|
||||
const task = await Task.create({ ...data, user_id: userId });
|
||||
|
||||
// Additional operations
|
||||
await taskEventService.logEvent(task.id, 'created');
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TaskService(); // Export singleton
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Patterns
|
||||
|
||||
### 1. Functional Components with Hooks
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Functional component
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
onUpdate: (task: Task) => void;
|
||||
}
|
||||
|
||||
export const TaskItem: React.FC<TaskItemProps> = ({ task, onUpdate }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Side effects here
|
||||
}, [task]);
|
||||
|
||||
return <div>{task.name}</div>;
|
||||
};
|
||||
|
||||
// ❌ Bad - Class components (legacy pattern)
|
||||
class TaskItem extends React.Component { ... }
|
||||
```
|
||||
|
||||
### 2. State Management
|
||||
|
||||
```typescript
|
||||
// Global state - Zustand
|
||||
import { useStore } from '../store/useStore';
|
||||
|
||||
const TaskList = () => {
|
||||
const tasks = useStore(state => state.tasks);
|
||||
const setTasks = useStore(state => state.setTasks);
|
||||
// ...
|
||||
};
|
||||
|
||||
// Server state - SWR
|
||||
import useSWR from 'swr';
|
||||
import { getTasks } from '../utils/tasksService';
|
||||
|
||||
const TaskList = () => {
|
||||
const { data: tasks, error, mutate } = useSWR('/api/v1/tasks', getTasks);
|
||||
// ...
|
||||
};
|
||||
|
||||
// Local component state - useState
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
```
|
||||
|
||||
### 3. Styling - Tailwind CSS
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Tailwind utility classes
|
||||
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{task.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
// Conditional classes
|
||||
<div className={`p-4 ${task.completed ? 'opacity-50 line-through' : ''}`}>
|
||||
{task.name}
|
||||
</div>
|
||||
|
||||
// Complex conditionals - use clsx or classnames
|
||||
import clsx from 'clsx';
|
||||
|
||||
<div className={clsx(
|
||||
'p-4 rounded-lg',
|
||||
task.completed && 'opacity-50 line-through',
|
||||
task.priority === 2 && 'border-l-4 border-red-500'
|
||||
)}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| **Files** | kebab-case | `recurring-task-service.js`, `task-item.tsx` |
|
||||
| **React Components** | PascalCase | `TaskItem.tsx`, `ProjectForm.tsx` |
|
||||
| **Functions** | camelCase | `findTaskById`, `createTask`, `handleSubmit` |
|
||||
| **Classes** | PascalCase | `TaskService`, `BaseRepository` |
|
||||
| **Constants** | UPPER_SNAKE_CASE | `API_VERSION`, `MAX_FILE_SIZE` |
|
||||
| **Variables** | camelCase | `userId`, `taskList`, `isCompleted` |
|
||||
| **Database Tables** | PascalCase | `Tasks`, `Projects`, `Users` |
|
||||
| **Database Columns** | snake_case | `user_id`, `due_date`, `created_at` |
|
||||
| **Interfaces (TS)** | PascalCase | `TaskProps`, `User`, `ApiResponse` |
|
||||
| **Type Aliases (TS)** | PascalCase | `TaskStatus`, `Priority` |
|
||||
|
||||
---
|
||||
|
||||
## API Route Conventions
|
||||
|
||||
```javascript
|
||||
// Singular for single resource operations
|
||||
POST /api/v1/task // Create new task
|
||||
GET /api/v1/task/:id // Get task by ID
|
||||
PUT /api/v1/task/:id // Update task
|
||||
DELETE /api/v1/task/:id // Delete task
|
||||
|
||||
// Plural for collection operations
|
||||
GET /api/v1/tasks // List all tasks
|
||||
GET /api/v1/tasks/today // Filtered list
|
||||
GET /api/v1/tasks/upcoming // Another filtered list
|
||||
|
||||
// UID support (alternative to numeric ID)
|
||||
GET /api/v1/task/uid/:uid // Get by UID
|
||||
|
||||
// Nested resources
|
||||
GET /api/v1/project/:id/tasks // Tasks for a project
|
||||
POST /api/v1/task/:id/tags // Add tags to task
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
Use appropriate status codes:
|
||||
|
||||
```javascript
|
||||
// Success
|
||||
200 OK // Successful GET, PUT
|
||||
201 Created // Successful POST
|
||||
204 No Content // Successful DELETE
|
||||
|
||||
// Client errors
|
||||
400 Bad Request // Invalid input
|
||||
401 Unauthorized // Not authenticated
|
||||
403 Forbidden // Not authorized
|
||||
404 Not Found // Resource doesn't exist
|
||||
409 Conflict // Duplicate resource
|
||||
|
||||
// Server errors
|
||||
500 Internal Server Error // Unexpected error
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// Success cases
|
||||
res.json(task); // 200 (default)
|
||||
res.status(201).json(task); // 201 for create
|
||||
res.status(204).send(); // 204 for delete
|
||||
|
||||
// Error cases
|
||||
res.status(400).json({ error: 'Invalid input' });
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Message Style
|
||||
|
||||
Based on git log history:
|
||||
|
||||
```bash
|
||||
# Use imperative mood
|
||||
git commit -m "Fix sidebar toggle causing unnecessary reload"
|
||||
git commit -m "Add priority level field to tasks"
|
||||
git commit -m "Update database migration for recurring tasks"
|
||||
|
||||
# Reference issues (if applicable)
|
||||
git commit -m "Fix subtask completion not persisting (#920)"
|
||||
|
||||
# Prefix releases
|
||||
git commit -m "release: v0.89.0"
|
||||
|
||||
# Multi-line for complex changes
|
||||
git commit -m "Add estimated time feature
|
||||
|
||||
- Add database migration for estimated_time field
|
||||
- Update Task model and serializers
|
||||
- Update Swagger documentation
|
||||
- Add UI field in TaskForm component
|
||||
- Add validation for positive values
|
||||
- Add integration tests"
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Start with a verb in imperative mood (Add, Fix, Update, Remove)
|
||||
- Keep first line under 72 characters
|
||||
- Reference issue numbers with (#123)
|
||||
- Use body for detailed explanations (if needed)
|
||||
|
||||
---
|
||||
|
||||
## Code Documentation
|
||||
|
||||
### JSDoc for Backend Functions
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Find all tasks for a user with optional filtering
|
||||
*
|
||||
* @param {number} userId - The user ID
|
||||
* @param {Object} filters - Optional filters
|
||||
* @param {string} [filters.status] - Filter by status
|
||||
* @param {string} [filters.priority] - Filter by priority
|
||||
* @returns {Promise<Task[]>} Array of tasks
|
||||
*/
|
||||
async function findAllTasks(userId, filters = {}) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript Interfaces
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Task entity representing a single task
|
||||
*/
|
||||
export interface Task {
|
||||
/** Unique numeric ID */
|
||||
id: number;
|
||||
|
||||
/** Unique string identifier */
|
||||
uid: string;
|
||||
|
||||
/** Task name/title */
|
||||
name: string;
|
||||
|
||||
/** Optional due date */
|
||||
due_date: string | null;
|
||||
|
||||
/** Priority: 0 (low), 1 (medium), 2 (high) */
|
||||
priority: 0 | 1 | 2;
|
||||
|
||||
/** Current status */
|
||||
status: TaskStatus;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Organization
|
||||
|
||||
### Backend Module Files
|
||||
|
||||
```
|
||||
/backend/modules/[module]/
|
||||
├── routes.js # Keep routes simple, delegate to operations
|
||||
├── repository.js # Only database queries
|
||||
├── operations/ # Complex business logic
|
||||
├── core/
|
||||
│ ├── serializers.js # Only formatting
|
||||
│ ├── builders.js # Only object construction
|
||||
│ └── parsers.js # Only parsing
|
||||
└── utils/
|
||||
└── validation.js # Only validation
|
||||
```
|
||||
|
||||
**Principle:** Single Responsibility - each file has one clear purpose
|
||||
|
||||
### Frontend Component Files
|
||||
|
||||
```
|
||||
/frontend/components/Task/
|
||||
├── TaskItem.tsx # Single task display
|
||||
├── TaskList.tsx # List of tasks
|
||||
├── TaskForm.tsx # Task creation/editing form
|
||||
├── TaskFilters.tsx # Filter controls
|
||||
└── SubtaskList.tsx # Subtask-specific component
|
||||
```
|
||||
|
||||
**Principle:** Feature-based organization, components stay focused
|
||||
|
||||
---
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
### Test File Naming
|
||||
|
||||
```
|
||||
// Backend
|
||||
/backend/tests/unit/services/taskService.test.js
|
||||
/backend/tests/integration/tasks/tasks.test.js
|
||||
|
||||
// Frontend
|
||||
/frontend/components/Task/__tests__/TaskItem.test.tsx
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```javascript
|
||||
describe('Feature or Component', () => {
|
||||
// Setup
|
||||
beforeEach(() => {
|
||||
// Arrange common setup
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
});
|
||||
|
||||
it('should do specific thing', () => {
|
||||
// Arrange
|
||||
const input = 'test';
|
||||
|
||||
// Act
|
||||
const result = functionUnderTest(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('expected');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Input Validation
|
||||
|
||||
```javascript
|
||||
// ✅ Always validate user input
|
||||
if (!data.name || typeof data.name !== 'string') {
|
||||
throw new ValidationError('Name is required');
|
||||
}
|
||||
|
||||
if (data.priority !== undefined) {
|
||||
const priority = parseInt(data.priority, 10);
|
||||
if (isNaN(priority) || priority < 0 || priority > 2) {
|
||||
throw new ValidationError('Invalid priority');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
```javascript
|
||||
// ✅ Good - Sequelize handles parameterization
|
||||
const tasks = await Task.findAll({
|
||||
where: { user_id: userId, name: { [Op.like]: `%${searchTerm}%` } }
|
||||
});
|
||||
|
||||
// ❌ Bad - Raw queries (if absolutely necessary, use replacements)
|
||||
await sequelize.query(
|
||||
'SELECT * FROM Tasks WHERE user_id = :userId',
|
||||
{
|
||||
replacements: { userId },
|
||||
type: QueryTypes.SELECT
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Password Handling
|
||||
|
||||
```javascript
|
||||
// ✅ Always hash passwords with bcrypt
|
||||
const bcrypt = require('bcrypt');
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// ❌ Never store plain text passwords
|
||||
user.password = password; // NEVER DO THIS
|
||||
```
|
||||
|
||||
### XSS Prevention
|
||||
|
||||
```typescript
|
||||
// ✅ React automatically escapes content
|
||||
<div>{task.name}</div>
|
||||
|
||||
// ⚠️ Only use dangerouslySetInnerHTML for trusted, sanitized content
|
||||
import DOMPurify from 'dompurify';
|
||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
### Database Queries
|
||||
|
||||
```javascript
|
||||
// ✅ Good - Use includes for associations
|
||||
const tasks = await Task.findAll({
|
||||
where: { user_id: userId },
|
||||
include: [Project, Tag] // Eager loading
|
||||
});
|
||||
|
||||
// ❌ Bad - N+1 queries
|
||||
const tasks = await Task.findAll({ where: { user_id: userId } });
|
||||
for (const task of tasks) {
|
||||
task.project = await Project.findByPk(task.project_id); // N+1!
|
||||
}
|
||||
```
|
||||
|
||||
### React Rendering
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Memoize expensive computations
|
||||
const sortedTasks = useMemo(
|
||||
() => tasks.sort((a, b) => a.priority - b.priority),
|
||||
[tasks]
|
||||
);
|
||||
|
||||
// ✅ Good - Prevent unnecessary re-renders
|
||||
const TaskItem = React.memo(({ task }) => {
|
||||
return <div>{task.name}</div>;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
540
docs/common-tasks.md
Normal file
540
docs/common-tasks.md
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
# Common Tasks Reference
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
Quick reference for frequently performed development tasks with complete file lists.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add New Field to Existing Model
|
||||
|
||||
**Example: Add `estimated_time` field to Task**
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. **Create migration**
|
||||
```bash
|
||||
npm run migration:create -- --name add-estimated-time-to-tasks
|
||||
```
|
||||
|
||||
Edit `/backend/migrations/YYYYMMDDHHMMSS-add-estimated-time-to-tasks.js`
|
||||
|
||||
2. **Update model**
|
||||
- `/backend/models/task.js` - Add field definition
|
||||
|
||||
3. **Update serializer**
|
||||
- `/backend/modules/tasks/core/serializers.js` - Add to `serializeTask()`
|
||||
|
||||
4. **Update builder**
|
||||
- `/backend/modules/tasks/core/builders.js` - Add to `buildTaskAttributes()`
|
||||
|
||||
5. **Add validation (if needed)**
|
||||
- `/backend/modules/tasks/routes.js` - Add validation logic
|
||||
|
||||
6. **Update API docs**
|
||||
- `/backend/config/swagger.js` - Update Task schema
|
||||
|
||||
7. **Update frontend types**
|
||||
- `/frontend/entities/Task.ts` - Add to interface (if exists)
|
||||
|
||||
8. **Update UI**
|
||||
- `/frontend/components/Task/TaskForm.tsx` - Add input field
|
||||
- `/frontend/components/Task/TaskItem.tsx` - Display field (if needed)
|
||||
|
||||
9. **Add tests**
|
||||
- `/backend/tests/integration/tasks/tasks.test.js` - Test CRUD with new field
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run migration:create -- --name add-estimated-time-to-tasks
|
||||
# Edit migration file
|
||||
npm run migration:run
|
||||
npm run backend:test
|
||||
npm run lint:fix
|
||||
npm run format:fix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create New Backend Module
|
||||
|
||||
**Example: Create "labels" module**
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Create directory structure**
|
||||
```bash
|
||||
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels
|
||||
```
|
||||
|
||||
2. **Create files**
|
||||
- `/backend/modules/labels/routes.js` - Express routes
|
||||
- `/backend/modules/labels/repository.js` - Data access
|
||||
|
||||
3. **Create model**
|
||||
- `/backend/models/label.js` - Sequelize model
|
||||
- Update `/backend/models/index.js` - Add associations
|
||||
|
||||
4. **Create migration**
|
||||
```bash
|
||||
npm run migration:create -- --name create-labels-table
|
||||
```
|
||||
- Edit `/backend/migrations/YYYYMMDDHHMMSS-create-labels-table.js`
|
||||
|
||||
5. **Register routes**
|
||||
- Edit `/backend/app.js`:
|
||||
```javascript
|
||||
app.use('/api/v1', require('./modules/labels/routes'));
|
||||
app.use('/api', require('./modules/labels/routes'));
|
||||
```
|
||||
|
||||
6. **Add Swagger docs**
|
||||
- Edit `/backend/config/swagger.js` - Add Label schema
|
||||
|
||||
7. **Write tests**
|
||||
- Create `/backend/tests/integration/labels/labels.test.js`
|
||||
|
||||
8. **Frontend integration (optional)**
|
||||
- `/frontend/entities/Label.ts` - TypeScript interface
|
||||
- `/frontend/utils/labelsService.ts` - API client
|
||||
- `/frontend/components/Label/` - Components
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run migration:create -- --name create-labels-table
|
||||
npm run migration:run
|
||||
npm run backend:test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add New React Component
|
||||
|
||||
**Example: Create `TaskPriorityBadge` component**
|
||||
|
||||
### Files to Create
|
||||
|
||||
1. **Component file**
|
||||
- `/frontend/components/Task/TaskPriorityBadge.tsx`
|
||||
|
||||
2. **Test file (optional)**
|
||||
- `/frontend/components/Task/__tests__/TaskPriorityBadge.test.tsx`
|
||||
|
||||
3. **Import in parent component**
|
||||
- `/frontend/components/Task/TaskItem.tsx` - Import and use
|
||||
|
||||
### Example Component
|
||||
|
||||
```typescript
|
||||
// TaskPriorityBadge.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface TaskPriorityBadgeProps {
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export const TaskPriorityBadge: React.FC<TaskPriorityBadgeProps> = ({ priority }) => {
|
||||
const configs = {
|
||||
0: { label: 'Low', className: 'bg-gray-200 text-gray-800' },
|
||||
1: { label: 'Medium', className: 'bg-yellow-200 text-yellow-800' },
|
||||
2: { label: 'High', className: 'bg-red-200 text-red-800' }
|
||||
};
|
||||
|
||||
const config = configs[priority] || configs[0];
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${config.className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run frontend:test
|
||||
npm run lint:fix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update Database Schema
|
||||
|
||||
**Example: Add index to Tasks table**
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Create migration**
|
||||
```bash
|
||||
npm run migration:create -- --name add-index-to-tasks-due-date
|
||||
```
|
||||
|
||||
2. **Edit migration file**
|
||||
`/backend/migrations/YYYYMMDDHHMMSS-add-index-to-tasks-due-date.js`
|
||||
```javascript
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addIndex('Tasks', ['due_date'], {
|
||||
name: 'tasks_due_date_index'
|
||||
});
|
||||
}
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeIndex('Tasks', 'tasks_due_date_index');
|
||||
}
|
||||
```
|
||||
|
||||
3. **Test migration**
|
||||
```bash
|
||||
npm run migration:run
|
||||
npm run migration:undo # Test rollback
|
||||
npm run migration:run # Re-apply
|
||||
```
|
||||
|
||||
4. **Update model (optional)**
|
||||
- Index definition can be in migration only, or also in model
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run migration:create -- --name add-index-to-tasks-due-date
|
||||
npm run migration:run
|
||||
npm run backend:test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Fix a Bug (TDD Workflow)
|
||||
|
||||
**Example: Fix bug where completed tasks show in Today view**
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Write failing test first**
|
||||
|
||||
Edit `/backend/tests/integration/tasks/tasks.test.js`:
|
||||
```javascript
|
||||
it('should not return completed tasks in Today view', async () => {
|
||||
// Arrange - Create completed task
|
||||
await Task.create({
|
||||
name: 'Completed Task',
|
||||
status: 2,
|
||||
due_date: new Date().toISOString().split('T')[0],
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
// Act
|
||||
const response = await request(app)
|
||||
.get('/api/v1/tasks/today')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(200);
|
||||
const completedTasks = response.body.filter(t => t.status === 2);
|
||||
expect(completedTasks.length).toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Run test - verify it fails**
|
||||
```bash
|
||||
npm run backend:test
|
||||
```
|
||||
|
||||
3. **Fix the bug**
|
||||
|
||||
Edit `/backend/modules/tasks/queries/query-builders.js` or `/backend/modules/tasks/operations/list.js`:
|
||||
```javascript
|
||||
// Add status filter to Today query
|
||||
where.status = { [Op.ne]: 2 }; // Exclude completed
|
||||
```
|
||||
|
||||
4. **Run test - verify it passes**
|
||||
```bash
|
||||
npm run backend:test
|
||||
```
|
||||
|
||||
5. **Run all checks**
|
||||
```bash
|
||||
npm run pre-push
|
||||
```
|
||||
|
||||
6. **Commit**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Fix completed tasks appearing in Today view (#123)
|
||||
|
||||
- Add status filter to Today query
|
||||
- Add test to prevent regression"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Translation Key
|
||||
|
||||
### Backend Translations
|
||||
|
||||
Backend typically doesn't use translations (API responses in English).
|
||||
|
||||
### Frontend Translations
|
||||
|
||||
1. **Add key to English source**
|
||||
|
||||
Edit `/public/locales/en/translation.json`:
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"estimatedTime": "Estimated Time",
|
||||
"estimatedTimePlaceholder": "Enter time in minutes",
|
||||
"estimatedTimeUnit": "minutes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use in component**
|
||||
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TaskForm = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>{t('task.estimatedTime')}</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder={t('task.estimatedTimePlaceholder')}
|
||||
/>
|
||||
<span>{t('task.estimatedTimeUnit')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
3. **Sync translations (if tool available)**
|
||||
```bash
|
||||
npm run translations:sync
|
||||
npm run translations:check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Add API Endpoint Documentation
|
||||
|
||||
### Update Swagger Schema
|
||||
|
||||
Edit `/backend/config/swagger.js`:
|
||||
|
||||
```javascript
|
||||
// Update existing schema
|
||||
components: {
|
||||
schemas: {
|
||||
Task: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
uid: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
estimated_time: {
|
||||
type: 'integer',
|
||||
description: 'Estimated time in minutes',
|
||||
nullable: true,
|
||||
example: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Document New Endpoint with JSDoc
|
||||
|
||||
In `/backend/modules/tasks/routes.js`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/task/{id}/estimate:
|
||||
* put:
|
||||
* summary: Update task estimated time
|
||||
* tags: [Tasks]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* estimated_time:
|
||||
* type: integer
|
||||
* description: Time in minutes
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Task updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Task'
|
||||
* 404:
|
||||
* description: Task not found
|
||||
*/
|
||||
router.put('/task/:id/estimate', async (req, res, next) => {
|
||||
// Implementation
|
||||
});
|
||||
```
|
||||
|
||||
### View Swagger Docs
|
||||
|
||||
1. Start server: `npm run backend:dev`
|
||||
2. Login to app
|
||||
3. Visit: http://localhost:3002/api-docs
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Add Subtask to Task
|
||||
|
||||
**Example: Add subtasks to a task**
|
||||
|
||||
This is already implemented in tududi, but here's the pattern:
|
||||
|
||||
### Files Involved
|
||||
|
||||
1. **Migration**
|
||||
- Already exists: `parent_task_id` field in Tasks table
|
||||
|
||||
2. **Model**
|
||||
- `/backend/models/task.js` - Self-referential association
|
||||
- `/backend/models/index.js` - Define relationship:
|
||||
```javascript
|
||||
Task.hasMany(Task, { as: 'Subtasks', foreignKey: 'parent_task_id' });
|
||||
Task.belongsTo(Task, { as: 'ParentTask', foreignKey: 'parent_task_id' });
|
||||
```
|
||||
|
||||
3. **Backend operations**
|
||||
- `/backend/modules/tasks/operations/subtasks.js` - Subtask CRUD
|
||||
|
||||
4. **Frontend**
|
||||
- `/frontend/components/Task/SubtaskList.tsx` - Display subtasks
|
||||
- `/frontend/components/Task/TaskDetails.tsx` - Show subtasks
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add Permission/Sharing to Resource
|
||||
|
||||
**Example: Add sharing to a new resource type**
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Ensure Permission model supports resource type**
|
||||
|
||||
Check `/backend/models/permission.js`:
|
||||
```javascript
|
||||
resource_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
// Should support your resource type
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use `hasAccess` middleware in routes**
|
||||
|
||||
```javascript
|
||||
const { hasAccess } = require('../../middleware/authorize');
|
||||
|
||||
router.get('/myresource/:id',
|
||||
hasAccess('ro', 'myresource', (req) => req.params.id),
|
||||
async (req, res, next) => {
|
||||
// Handler
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
3. **Implement sharing endpoints**
|
||||
|
||||
See `/backend/modules/shares/` for reference
|
||||
|
||||
4. **Frontend sharing UI**
|
||||
|
||||
See `/frontend/components/Project/ProjectSharing.tsx` for reference
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Add Recurring Pattern to Task
|
||||
|
||||
**Example: Add new recurrence type**
|
||||
|
||||
### Files to Check/Modify
|
||||
|
||||
1. **Task model**
|
||||
- `/backend/models/task.js` - Recurrence fields
|
||||
|
||||
2. **Recurring task service**
|
||||
- `/backend/modules/tasks/recurringTaskService.js` - Add new pattern logic
|
||||
|
||||
3. **Task scheduler**
|
||||
- `/backend/modules/tasks/taskScheduler.js` - Ensure cron job handles new pattern
|
||||
|
||||
4. **Frontend**
|
||||
- `/frontend/components/Task/TaskForm.tsx` - Add UI for new pattern
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Export Data (Backup)
|
||||
|
||||
**Example: Create database backup**
|
||||
|
||||
Already implemented in tududi:
|
||||
|
||||
```bash
|
||||
# Via API (after login)
|
||||
POST /api/v1/backup
|
||||
|
||||
# Returns JSON file with all user data
|
||||
```
|
||||
|
||||
See `/backend/modules/backup/` for implementation details.
|
||||
|
||||
---
|
||||
|
||||
## Quick Command Reference
|
||||
|
||||
```bash
|
||||
# Database
|
||||
npm run db:init # Initialize database
|
||||
npm run db:reset # Reset database
|
||||
npm run migration:create -- --name # Create migration
|
||||
npm run migration:run # Run migrations
|
||||
npm run migration:undo # Rollback migration
|
||||
|
||||
# Development
|
||||
npm start # Run both frontend + backend
|
||||
npm run backend:dev # Backend only
|
||||
npm run frontend:dev # Frontend only
|
||||
|
||||
# Testing
|
||||
npm test # Backend tests
|
||||
npm run frontend:test # Frontend tests
|
||||
npm run test:ui # E2E tests
|
||||
npm run test:coverage # Coverage report
|
||||
|
||||
# Code Quality
|
||||
npm run lint # Check linting
|
||||
npm run lint:fix # Fix linting
|
||||
npm run format:fix # Format code
|
||||
npm run pre-push # All checks
|
||||
|
||||
# Build
|
||||
npm run build # Production build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
508
docs/database.md
Normal file
508
docs/database.md
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
# Database & Migrations
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Key Models
|
||||
|
||||
All Sequelize models are defined in `/backend/models/` and associations are configured in `/backend/models/index.js`.
|
||||
|
||||
### Core Models
|
||||
|
||||
| Model | File | Purpose | Key Fields |
|
||||
|-------|------|---------|------------|
|
||||
| **User** | `user.js` | User accounts | email, password (bcrypt), settings, preferences, timezone |
|
||||
| **Task** | `task.js` | Tasks with recurrence | name, due_date, priority, status, recurrence_type, parent_task_id |
|
||||
| **Project** | `project.js` | Project grouping | name, area_id, user_id |
|
||||
| **Area** | `area.js` | Area categorization | name, user_id |
|
||||
| **Note** | `note.js` | Notes | text, project_id, user_id |
|
||||
| **Tag** | `tag.js` | Tags | name, color, user_id |
|
||||
| **Permission** | `permission.js` | Sharing/permissions | user_id, resource_type, resource_uid, access_level |
|
||||
| **ApiToken** | `apiToken.js` | API tokens | user_id, token_hash, expires_at |
|
||||
| **RecurringCompletion** | `recurringCompletion.js` | Recurring task history | task_id, completed_at, due_date |
|
||||
| **TaskEvent** | `taskEvent.js` | Task audit log | task_id, user_id, action, changes |
|
||||
| **TaskAttachment** | `taskAttachment.js` | File attachments | task_id, filename, path |
|
||||
| **InboxItem** | `inboxItem.js` | Inbox entries | name, user_id |
|
||||
| **Notification** | `notification.js` | User notifications | user_id, type, read, linked_resource |
|
||||
| **Role** | `role.js` | User roles | name, is_admin |
|
||||
| **View** | `view.js` | Saved views | name, filters, user_id |
|
||||
| **Backup** | `backup.js` | Backup records | user_id, filename, created_at |
|
||||
|
||||
---
|
||||
|
||||
## Model Relationships
|
||||
|
||||
Defined in `/backend/models/index.js`:
|
||||
|
||||
```javascript
|
||||
// User relationships (one user has many resources)
|
||||
User.hasMany(Task, { foreignKey: 'user_id' });
|
||||
User.hasMany(Project, { foreignKey: 'user_id' });
|
||||
User.hasMany(Area, { foreignKey: 'user_id' });
|
||||
User.hasMany(Note, { foreignKey: 'user_id' });
|
||||
|
||||
// Hierarchical relationships
|
||||
// Area > Project
|
||||
Area.hasMany(Project, { foreignKey: 'area_id' });
|
||||
Project.belongsTo(Area, { foreignKey: 'area_id' });
|
||||
|
||||
// Project > Task
|
||||
Project.hasMany(Task, { foreignKey: 'project_id' });
|
||||
Task.belongsTo(Project, { foreignKey: 'project_id' });
|
||||
|
||||
// Project > Note
|
||||
Project.hasMany(Note, { foreignKey: 'project_id' });
|
||||
Note.belongsTo(Project, { foreignKey: 'project_id' });
|
||||
|
||||
// Self-referential (subtasks and recurring tasks)
|
||||
Task.hasMany(Task, { as: 'Subtasks', foreignKey: 'parent_task_id' });
|
||||
Task.belongsTo(Task, { as: 'ParentTask', foreignKey: 'parent_task_id' });
|
||||
|
||||
// Many-to-many relationships (tags)
|
||||
Task.belongsToMany(Tag, { through: 'tasks_tags' });
|
||||
Tag.belongsToMany(Task, { through: 'tasks_tags' });
|
||||
|
||||
Project.belongsToMany(Tag, { through: 'projects_tags' });
|
||||
Tag.belongsToMany(Project, { through: 'projects_tags' });
|
||||
|
||||
Note.belongsToMany(Tag, { through: 'notes_tags' });
|
||||
Tag.belongsToMany(Note, { through: 'notes_tags' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
### Creating a New Migration
|
||||
|
||||
```bash
|
||||
npm run migration:create -- --name add-field-to-tasks
|
||||
```
|
||||
|
||||
This creates: `/backend/migrations/YYYYMMDDHHMMSS-add-field-to-tasks.js`
|
||||
|
||||
The timestamp ensures migrations run in chronological order.
|
||||
|
||||
### Migration File Template
|
||||
|
||||
```javascript
|
||||
// backend/migrations/20260313120000-add-priority-level-to-tasks.js
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Changes to apply
|
||||
await queryInterface.addColumn('Tasks', 'priority_level', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// How to reverse the changes
|
||||
await queryInterface.removeColumn('Tasks', 'priority_level');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Running Migrations
|
||||
|
||||
```bash
|
||||
# Apply all pending migrations
|
||||
npm run migration:run
|
||||
|
||||
# Rollback last migration
|
||||
npm run migration:undo
|
||||
|
||||
# Check migration status
|
||||
npm run db:status
|
||||
|
||||
# Rollback all migrations (CAREFUL!)
|
||||
npm run migration:undo:all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
### 1. Always Implement `down` Method
|
||||
|
||||
Make migrations reversible:
|
||||
|
||||
```javascript
|
||||
// ✅ Good - Reversible
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('Tasks', 'priority_level');
|
||||
}
|
||||
|
||||
// ❌ Bad - Not reversible
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Not implemented
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Test Both Directions
|
||||
|
||||
```bash
|
||||
# Test up
|
||||
npm run migration:run
|
||||
|
||||
# Test down (rollback)
|
||||
npm run migration:undo
|
||||
|
||||
# Re-apply to verify
|
||||
npm run migration:run
|
||||
```
|
||||
|
||||
### 3. Never Modify Released Migrations
|
||||
|
||||
Once a migration is released (in main branch or deployed), create a new one instead:
|
||||
|
||||
```bash
|
||||
# ❌ Bad - Modifying existing migration
|
||||
# Edit: migrations/20260101-create-tasks.js
|
||||
|
||||
# ✅ Good - Create new migration
|
||||
npm run migration:create -- --name modify-tasks-table
|
||||
```
|
||||
|
||||
### 4. Update Corresponding Model
|
||||
|
||||
After creating migration, update the Sequelize model:
|
||||
|
||||
```javascript
|
||||
// Migration adds field
|
||||
await queryInterface.addColumn('Tasks', 'estimated_time', {
|
||||
type: Sequelize.INTEGER
|
||||
});
|
||||
|
||||
// Update /backend/models/task.js
|
||||
Task.init({
|
||||
// ... existing fields ...
|
||||
estimated_time: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Use Transactions for Data Migrations
|
||||
|
||||
```javascript
|
||||
async up(queryInterface, Sequelize) {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Multiple operations in transaction
|
||||
await queryInterface.addColumn('Tasks', 'new_field', {...}, { transaction });
|
||||
|
||||
// Data migration
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE Tasks SET new_field = old_field WHERE old_field IS NOT NULL',
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.removeColumn('Tasks', 'old_field', { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Include Migrations in PRs
|
||||
|
||||
When schema changes are needed, always include the migration file in your PR.
|
||||
|
||||
---
|
||||
|
||||
## Common Migration Operations
|
||||
|
||||
### Add Column
|
||||
|
||||
```javascript
|
||||
await queryInterface.addColumn('TableName', 'column_name', {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'default'
|
||||
});
|
||||
```
|
||||
|
||||
### Remove Column
|
||||
|
||||
```javascript
|
||||
await queryInterface.removeColumn('TableName', 'column_name');
|
||||
```
|
||||
|
||||
### Change Column Type
|
||||
|
||||
```javascript
|
||||
await queryInterface.changeColumn('TableName', 'column_name', {
|
||||
type: Sequelize.TEXT, // Changed from STRING
|
||||
allowNull: true
|
||||
});
|
||||
```
|
||||
|
||||
### Rename Column
|
||||
|
||||
```javascript
|
||||
await queryInterface.renameColumn('TableName', 'old_name', 'new_name');
|
||||
```
|
||||
|
||||
### Add Index
|
||||
|
||||
```javascript
|
||||
await queryInterface.addIndex('TableName', ['column_name'], {
|
||||
name: 'index_name',
|
||||
unique: false
|
||||
});
|
||||
```
|
||||
|
||||
### Remove Index
|
||||
|
||||
```javascript
|
||||
await queryInterface.removeIndex('TableName', 'index_name');
|
||||
```
|
||||
|
||||
### Create Table
|
||||
|
||||
```javascript
|
||||
await queryInterface.createTable('NewTable', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
uid: {
|
||||
type: Sequelize.STRING(15),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Drop Table
|
||||
|
||||
```javascript
|
||||
await queryInterface.dropTable('TableName');
|
||||
```
|
||||
|
||||
### Add Foreign Key
|
||||
|
||||
```javascript
|
||||
await queryInterface.addConstraint('Tasks', {
|
||||
fields: ['project_id'],
|
||||
type: 'foreign key',
|
||||
name: 'tasks_project_fk',
|
||||
references: {
|
||||
table: 'Projects',
|
||||
field: 'id'
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Configuration
|
||||
|
||||
### Location
|
||||
|
||||
`/backend/config/database.js`
|
||||
|
||||
### SQLite Performance Optimizations
|
||||
|
||||
```javascript
|
||||
// Applied in models/index.js
|
||||
await sequelize.query('PRAGMA journal_mode=WAL'); // Write-Ahead Logging
|
||||
await sequelize.query('PRAGMA synchronous=NORMAL'); // Faster with single user
|
||||
await sequelize.query('PRAGMA busy_timeout=5000'); // 5s timeout
|
||||
await sequelize.query('PRAGMA cache_size=-64000'); // 64MB cache
|
||||
await sequelize.query('PRAGMA mmap_size=268435456'); // 256MB memory-mapped I/O
|
||||
await sequelize.query('PRAGMA temp_store=MEMORY'); // Memory-based temp storage
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- WAL mode: Better concurrency for writes
|
||||
- Larger cache: Fewer disk reads
|
||||
- Memory-mapped I/O: Faster random access
|
||||
- Memory temp storage: Faster temp operations
|
||||
|
||||
### Database File Location
|
||||
|
||||
- **Development:** `/backend/database.sqlite`
|
||||
- **Docker:** Mounted volume at `/app/backend/db/`
|
||||
- **Test:** Separate test database (auto-created)
|
||||
|
||||
---
|
||||
|
||||
## Database Management Commands
|
||||
|
||||
```bash
|
||||
# Initialize database (create + migrate)
|
||||
npm run db:init
|
||||
|
||||
# Reset database (WIPES ALL DATA!)
|
||||
npm run db:reset
|
||||
|
||||
# Seed development data
|
||||
npm run db:seed
|
||||
|
||||
# Check migration status
|
||||
npm run db:status
|
||||
|
||||
# Create new migration
|
||||
npm run migration:create -- --name description
|
||||
|
||||
# Run pending migrations
|
||||
npm run migration:run
|
||||
|
||||
# Rollback last migration
|
||||
npm run migration:undo
|
||||
|
||||
# Rollback all migrations (CAREFUL!)
|
||||
npm run migration:undo:all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Definition Example
|
||||
|
||||
```javascript
|
||||
// /backend/models/task.js
|
||||
'use strict';
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
class Task extends Model {
|
||||
static associate(models) {
|
||||
// Defined in models/index.js
|
||||
}
|
||||
}
|
||||
|
||||
Task.init({
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
uid: {
|
||||
type: DataTypes.STRING(15),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
due_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 2
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
// Recurrence fields
|
||||
recurrence_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
recurrence_interval: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
// Foreign keys
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
parent_task_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'Task',
|
||||
tableName: 'Tasks',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
return Task;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Task Model Fields
|
||||
|
||||
The Task model has special fields for recurring tasks:
|
||||
|
||||
```javascript
|
||||
// Recurrence pattern
|
||||
recurrence_type: 'daily' | 'weekly' | 'monthly' | 'monthly_weekday' | 'monthly_last_day'
|
||||
recurrence_interval: 1, 2, 3, ... (every N days/weeks/months)
|
||||
recurrence_end_date: Optional end date for series
|
||||
|
||||
// Relationships
|
||||
parent_task_id: Links subtasks to parent task (self-referential)
|
||||
recurring_parent_id: Links recurring instances to original pattern
|
||||
```
|
||||
|
||||
**Task Status Values:**
|
||||
- 0: Not started
|
||||
- 1: In progress
|
||||
- 2: Done/Completed
|
||||
- 3: Archived
|
||||
- 4: Waiting
|
||||
- 5: Cancelled
|
||||
- 6: Planned
|
||||
|
||||
**Priority Values:**
|
||||
- 0: Low
|
||||
- 1: Medium
|
||||
- 2: High
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
580
docs/development-workflow.md
Normal file
580
docs/development-workflow.md
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
# Development Workflow
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** v22+ (recommended - check package.json engines field)
|
||||
- **npm** (comes with Node.js)
|
||||
- **Git**
|
||||
|
||||
### Clone and Install
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/chrisvel/tududi.git
|
||||
cd tududi
|
||||
|
||||
# Install all dependencies
|
||||
# This installs both frontend and backend dependencies (monorepo setup)
|
||||
npm install
|
||||
```
|
||||
|
||||
### Initialize Database
|
||||
|
||||
```bash
|
||||
# Create database and run all migrations
|
||||
npm run db:init
|
||||
|
||||
# This command:
|
||||
# 1. Creates /backend/database.sqlite
|
||||
# 2. Runs all migrations from /backend/migrations/
|
||||
# 3. Sets up tables and relationships
|
||||
```
|
||||
|
||||
### Create Test User (Optional)
|
||||
|
||||
```bash
|
||||
npm run user:create
|
||||
|
||||
# Interactive prompts:
|
||||
# - Email address
|
||||
# - Password
|
||||
# - Timezone (defaults to system timezone)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Daily Development
|
||||
|
||||
Tududi runs two separate processes during development:
|
||||
|
||||
### Two-Server Development
|
||||
|
||||
**Terminal 1 - Backend (Express):**
|
||||
```bash
|
||||
npm run backend:dev
|
||||
|
||||
# Details:
|
||||
# - Runs: nodemon backend/app.js
|
||||
# - Server: http://localhost:3002
|
||||
# - Auto-reloads: Yes (on file changes)
|
||||
# - API endpoints: /api/v1/*
|
||||
# - Swagger docs: /api-docs (after login)
|
||||
```
|
||||
|
||||
**Terminal 2 - Frontend (Webpack Dev Server):**
|
||||
```bash
|
||||
npm run frontend:dev
|
||||
|
||||
# Details:
|
||||
# - Runs: webpack serve --mode development
|
||||
# - Server: http://localhost:8080
|
||||
# - Hot reload: Yes (React Fast Refresh)
|
||||
# - Proxies /api/* to backend:3002
|
||||
# - Proxies /locales/* to backend:3002
|
||||
```
|
||||
|
||||
**Or run both simultaneously:**
|
||||
```bash
|
||||
npm start
|
||||
|
||||
# Runs both backend:dev and frontend:dev in parallel
|
||||
# Uses 'concurrently' package
|
||||
# Logs from both processes interleaved
|
||||
```
|
||||
|
||||
### Accessing the Application
|
||||
|
||||
Open http://localhost:8080 in your browser.
|
||||
|
||||
**Login with:**
|
||||
- Email: The email you set during db:init or user:create
|
||||
- Password: The password you set
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `/backend/.env` file (not tracked in git):
|
||||
|
||||
```bash
|
||||
# Required
|
||||
TUDUDI_SESSION_SECRET=your-random-secret-here-use-openssl-rand-hex-64
|
||||
TUDUDI_USER_EMAIL=admin@example.com
|
||||
TUDUDI_USER_PASSWORD=your-secure-password
|
||||
|
||||
# Optional - Server config
|
||||
NODE_ENV=development
|
||||
DB_FILE=database.sqlite
|
||||
FRONTEND_URL=http://localhost:8080
|
||||
BACKEND_URL=http://localhost:3002
|
||||
PORT=3002
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Optional - Email
|
||||
ENABLE_EMAIL=false
|
||||
EMAIL_SMTP_HOST=smtp.example.com
|
||||
EMAIL_SMTP_PORT=587
|
||||
EMAIL_SMTP_SECURE=false
|
||||
EMAIL_SMTP_USERNAME=user
|
||||
EMAIL_SMTP_PASSWORD=pass
|
||||
EMAIL_FROM_ADDRESS=noreply@example.com
|
||||
EMAIL_FROM_NAME=Tududi
|
||||
|
||||
# Optional - Integrations
|
||||
DISABLE_TELEGRAM=false
|
||||
GOOGLE_CLIENT_ID=your-google-oauth-client-id
|
||||
GOOGLE_CLIENT_SECRET=your-google-oauth-secret
|
||||
GOOGLE_REDIRECT_URI=http://localhost:8080/auth/google/callback
|
||||
|
||||
# Optional - Features
|
||||
DISABLE_SCHEDULER=false
|
||||
SWAGGER_ENABLED=true
|
||||
RATE_LIMITING_ENABLED=true
|
||||
|
||||
# Optional - Proxy (if behind reverse proxy)
|
||||
TUDUDI_TRUST_PROXY=false
|
||||
TUDUDI_ALLOWED_ORIGINS=http://localhost:8080
|
||||
|
||||
# Optional - Registration
|
||||
REGISTRATION_TOKEN_EXPIRY_HOURS=24
|
||||
```
|
||||
|
||||
**Generate secure session secret:**
|
||||
```bash
|
||||
openssl rand -hex 64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Feature (Complete Example)
|
||||
|
||||
**Example: Add "estimated_time" field to tasks**
|
||||
|
||||
This walkthrough shows all files to touch when adding a new field to an existing model.
|
||||
|
||||
### Step 1: Create Database Migration
|
||||
|
||||
```bash
|
||||
npm run migration:create -- --name add-estimated-time-to-tasks
|
||||
```
|
||||
|
||||
Edit the created file `/backend/migrations/YYYYMMDDHHMMSS-add-estimated-time-to-tasks.js`:
|
||||
|
||||
```javascript
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('Tasks', 'estimated_time', {
|
||||
type: Sequelize.INTEGER, // minutes
|
||||
allowNull: true,
|
||||
defaultValue: null
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('Tasks', 'estimated_time');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Run migration:**
|
||||
```bash
|
||||
npm run migration:run
|
||||
```
|
||||
|
||||
### Step 2: Update Sequelize Model
|
||||
|
||||
Edit `/backend/models/task.js`:
|
||||
|
||||
```javascript
|
||||
Task.init({
|
||||
// ... existing fields ...
|
||||
estimated_time: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Estimated time in minutes'
|
||||
},
|
||||
// ... rest of fields ...
|
||||
}, {
|
||||
sequelize,
|
||||
modelName: 'Task',
|
||||
tableName: 'Tasks'
|
||||
});
|
||||
```
|
||||
|
||||
### Step 3: Update Serializer (API Response)
|
||||
|
||||
Edit `/backend/modules/tasks/core/serializers.js`:
|
||||
|
||||
```javascript
|
||||
function serializeTask(task) {
|
||||
return {
|
||||
// ... existing fields ...
|
||||
estimated_time: task.estimated_time,
|
||||
// ... rest of fields ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Builder (API Input)
|
||||
|
||||
Edit `/backend/modules/tasks/core/builders.js`:
|
||||
|
||||
```javascript
|
||||
function buildTaskAttributes(data, userId) {
|
||||
const attributes = {
|
||||
// ... existing fields ...
|
||||
estimated_time: data.estimated_time ? parseInt(data.estimated_time, 10) : null,
|
||||
// ... rest of fields ...
|
||||
};
|
||||
|
||||
return attributes;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Add Validation (Optional)
|
||||
|
||||
If validation needed, edit `/backend/modules/tasks/routes.js`:
|
||||
|
||||
```javascript
|
||||
router.put('/task/:id', async (req, res, next) => {
|
||||
try {
|
||||
// Validate estimated_time
|
||||
if (req.body.estimated_time !== undefined) {
|
||||
const time = parseInt(req.body.estimated_time, 10);
|
||||
if (isNaN(time) || time < 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Estimated time must be a positive number'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of route handler ...
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Step 6: Update Swagger Documentation
|
||||
|
||||
Edit `/backend/config/swagger.js`:
|
||||
|
||||
```javascript
|
||||
// Find Task schema
|
||||
components: {
|
||||
schemas: {
|
||||
Task: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
// ... existing properties ...
|
||||
estimated_time: {
|
||||
type: 'integer',
|
||||
description: 'Estimated time in minutes',
|
||||
nullable: true,
|
||||
example: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Update Frontend TypeScript Interface
|
||||
|
||||
If TypeScript interface exists, edit `/frontend/entities/Task.ts`:
|
||||
|
||||
```typescript
|
||||
export interface Task {
|
||||
// ... existing fields ...
|
||||
estimated_time: number | null;
|
||||
// ... rest of fields ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Update Frontend Component
|
||||
|
||||
Edit `/frontend/components/Task/TaskForm.tsx`:
|
||||
|
||||
```typescript
|
||||
// Add input field
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Estimated Time (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={task.estimated_time || ''}
|
||||
onChange={(e) => updateTask({
|
||||
...task,
|
||||
estimated_time: e.target.value ? parseInt(e.target.value) : null
|
||||
})}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Step 9: Write Tests
|
||||
|
||||
Add tests in `/backend/tests/integration/tasks/tasks.test.js`:
|
||||
|
||||
```javascript
|
||||
it('should create task with estimated_time', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/task')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Task with estimate',
|
||||
estimated_time: 60
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.estimated_time).toBe(60);
|
||||
});
|
||||
|
||||
it('should reject negative estimated_time', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/v1/task')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Invalid task',
|
||||
estimated_time: -10
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
```
|
||||
|
||||
### Step 10: Run Tests and Checks
|
||||
|
||||
```bash
|
||||
# Run backend tests
|
||||
npm run backend:test
|
||||
|
||||
# Run linting
|
||||
npm run lint:fix
|
||||
|
||||
# Format code
|
||||
npm run format:fix
|
||||
|
||||
# Run all pre-push checks
|
||||
npm run pre-push
|
||||
```
|
||||
|
||||
### Step 11: Commit Changes
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add estimated_time field to tasks
|
||||
|
||||
- Add database migration for estimated_time
|
||||
- Update Task model and serializers
|
||||
- Update Swagger documentation
|
||||
- Add validation for positive values
|
||||
- Add UI field in TaskForm component
|
||||
- Add integration tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Management
|
||||
|
||||
```bash
|
||||
# Reset database (WIPES ALL DATA!)
|
||||
npm run db:reset
|
||||
|
||||
# Seed development data
|
||||
npm run db:seed
|
||||
|
||||
# Check migration status
|
||||
npm run db:status
|
||||
|
||||
# Create new migration
|
||||
npm run migration:create -- --name description
|
||||
|
||||
# Run pending migrations
|
||||
npm run migration:run
|
||||
|
||||
# Rollback last migration
|
||||
npm run migration:undo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Quality
|
||||
|
||||
```bash
|
||||
# Check linting
|
||||
npm run lint
|
||||
|
||||
# Auto-fix linting issues
|
||||
npm run lint:fix
|
||||
|
||||
# Format code with Prettier
|
||||
npm run format:fix
|
||||
|
||||
# Run all pre-push checks
|
||||
# (lint + format + tests)
|
||||
npm run pre-push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Backend tests
|
||||
npm test
|
||||
# or
|
||||
npm run backend:test
|
||||
|
||||
# Frontend tests
|
||||
npm run frontend:test
|
||||
|
||||
# E2E tests
|
||||
npm run test:ui # Headless
|
||||
npm run test:ui:headed # With browser visible
|
||||
|
||||
# Coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Watch mode (during development)
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
From CONTRIBUTING.md conventions:
|
||||
|
||||
```bash
|
||||
# Feature branches
|
||||
git checkout -b feature/description
|
||||
|
||||
# Bug fix branches
|
||||
git checkout -b fix/description
|
||||
|
||||
# Refactoring branches
|
||||
git checkout -b refactor/description
|
||||
|
||||
# Documentation branches
|
||||
git checkout -b docs/description
|
||||
|
||||
# Test branches
|
||||
git checkout -b test/description
|
||||
```
|
||||
|
||||
### Example Workflow
|
||||
|
||||
```bash
|
||||
# Create feature branch from main
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git checkout -b feature/estimated-time
|
||||
|
||||
# Make changes, run tests
|
||||
npm run pre-push
|
||||
|
||||
# Commit changes
|
||||
git add .
|
||||
git commit -m "Add estimated time feature"
|
||||
|
||||
# Before PR: rebase on main
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git checkout feature/estimated-time
|
||||
git rebase main
|
||||
|
||||
# Push and create PR
|
||||
git push origin feature/estimated-time
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build for Production
|
||||
|
||||
```bash
|
||||
# Build frontend
|
||||
npm run build
|
||||
|
||||
# Builds to: /dist/
|
||||
# - Minified JavaScript
|
||||
# - Optimized CSS
|
||||
# - Hashed filenames for cache busting
|
||||
```
|
||||
|
||||
### Docker Production
|
||||
|
||||
```bash
|
||||
# Build Docker image
|
||||
docker build -t tududi:latest .
|
||||
|
||||
# Run container
|
||||
docker run \
|
||||
-e TUDUDI_USER_EMAIL=admin@example.com \
|
||||
-e TUDUDI_USER_PASSWORD=secure-password \
|
||||
-e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \
|
||||
-v ~/tududi_db:/app/backend/db \
|
||||
-v ~/tududi_uploads:/app/backend/uploads \
|
||||
-p 3002:3002 \
|
||||
-d tududi:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```bash
|
||||
# Find process using port 3002
|
||||
lsof -ti:3002
|
||||
|
||||
# Kill process
|
||||
kill -9 $(lsof -ti:3002)
|
||||
|
||||
# Or use different port
|
||||
PORT=3003 npm run backend:dev
|
||||
```
|
||||
|
||||
### Database Locked
|
||||
|
||||
```bash
|
||||
# Stop all servers
|
||||
# Delete database file
|
||||
rm backend/database.sqlite
|
||||
|
||||
# Reinitialize
|
||||
npm run db:init
|
||||
```
|
||||
|
||||
### Module Not Found
|
||||
|
||||
```bash
|
||||
# Clean install
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### Webpack Build Errors
|
||||
|
||||
```bash
|
||||
# Clear webpack cache
|
||||
rm -rf node_modules/.cache
|
||||
|
||||
# Rebuild
|
||||
npm run frontend:dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
457
docs/directory-structure.md
Normal file
457
docs/directory-structure.md
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
# Directory Structure
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Project Root
|
||||
|
||||
```
|
||||
/Users/chris/c0deLab/ProjectLand/tududi/
|
||||
├── README.md # User-facing documentation
|
||||
├── CLAUDE.md # This developer guide (index)
|
||||
├── LICENSE # MIT License
|
||||
├── package.json # Root scripts and dependencies (monorepo)
|
||||
├── package-lock.json # Dependency lock file
|
||||
│
|
||||
├── Configuration Files
|
||||
├── webpack.config.js # Frontend build configuration
|
||||
├── tsconfig.json # TypeScript config (frontend only)
|
||||
├── jest.config.js # Jest config for frontend tests
|
||||
├── babel.config.js # Babel transpilation for Jest + Webpack
|
||||
├── eslint.config.mjs # ESLint flat config
|
||||
├── .prettierrc.json # Prettier code formatting
|
||||
├── tailwind.config.js # Tailwind CSS customization
|
||||
├── .sequelizerc # Sequelize CLI configuration
|
||||
├── postcss.config.js # PostCSS config for Tailwind
|
||||
│
|
||||
├── Docker & Deployment
|
||||
├── Dockerfile # Production Docker image (multi-stage)
|
||||
├── docker-compose.yml # Development Docker setup
|
||||
├── .dockerignore # Docker build exclusions
|
||||
│
|
||||
├── Git & GitHub
|
||||
├── .gitignore
|
||||
├── .github/
|
||||
│ ├── CONTRIBUTING.md # Contribution guidelines
|
||||
│ └── workflows/ # GitHub Actions (if any)
|
||||
│
|
||||
├── Source Code
|
||||
├── backend/ # Express backend → See Backend Structure
|
||||
├── frontend/ # React frontend → See Frontend Structure
|
||||
├── public/ # Static assets (fonts, locales, images)
|
||||
├── dist/ # Production build output
|
||||
├── e2e/ # Playwright E2E tests
|
||||
├── scripts/ # Build and utility scripts
|
||||
├── docs/ # Documentation (this directory)
|
||||
│
|
||||
└── Other
|
||||
├── screenshots/ # App screenshots for README
|
||||
├── uploads/ # User file uploads (not in git)
|
||||
├── test-results/ # Playwright test results
|
||||
└── node_modules/ # Dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Structure
|
||||
|
||||
```
|
||||
/Users/chris/c0deLab/ProjectLand/tududi/backend/
|
||||
│
|
||||
├── app.js # Main Express application entry point
|
||||
│ # - Middleware setup (Helmet, CORS, compression)
|
||||
│ # - Session management
|
||||
│ # - Rate limiting
|
||||
│ # - Module registration
|
||||
│ # - Swagger integration
|
||||
│ # - SPA fallback routing
|
||||
│
|
||||
├── modules/ # Feature modules (modular architecture)
|
||||
│ │
|
||||
│ ├── tasks/ # Task management (MOST COMPLEX MODULE)
|
||||
│ │ ├── routes.js # Express routes
|
||||
│ │ ├── repository.js # Data access layer
|
||||
│ │ ├── recurringTaskService.js
|
||||
│ │ ├── taskEventService.js
|
||||
│ │ ├── taskScheduler.js # Cron-based scheduling
|
||||
│ │ ├── operations/ # Business logic operations
|
||||
│ │ │ ├── list.js # List operations
|
||||
│ │ │ ├── completion.js # Status changes
|
||||
│ │ │ ├── recurring.js # Recurrence handling
|
||||
│ │ │ ├── subtasks.js # Subtask CRUD
|
||||
│ │ │ ├── tags.js # Tag assignment
|
||||
│ │ │ ├── grouping.js # Grouping logic
|
||||
│ │ │ ├── sorting.js # Sort orders
|
||||
│ │ │ └── parent-child.js # Hierarchy ops
|
||||
│ │ ├── queries/ # Query builders
|
||||
│ │ │ ├── query-builders.js
|
||||
│ │ │ ├── metrics-queries.js
|
||||
│ │ │ └── metrics-computation.js
|
||||
│ │ ├── core/ # Core utilities
|
||||
│ │ │ ├── serializers.js # Format API responses
|
||||
│ │ │ ├── parsers.js # Parse request data
|
||||
│ │ │ ├── builders.js # Build database objects
|
||||
│ │ │ └── comparators.js # Detect changes
|
||||
│ │ ├── middleware/
|
||||
│ │ │ └── access.js # Access control
|
||||
│ │ └── utils/
|
||||
│ │ ├── constants.js
|
||||
│ │ ├── validation.js
|
||||
│ │ └── logging.js
|
||||
│ │
|
||||
│ ├── projects/ # Project management
|
||||
│ │ ├── routes.js
|
||||
│ │ ├── repository.js
|
||||
│ │ └── utils/
|
||||
│ │ └── validation.js
|
||||
│ │
|
||||
│ ├── areas/ # Area organization
|
||||
│ ├── notes/ # Notes management
|
||||
│ ├── tags/ # Tag system
|
||||
│ ├── users/ # User management
|
||||
│ ├── auth/ # Authentication (login/register)
|
||||
│ ├── shares/ # Project sharing & permissions
|
||||
│ ├── telegram/ # Telegram bot integration
|
||||
│ ├── inbox/ # Inbox items
|
||||
│ ├── habits/ # Habit tracking
|
||||
│ ├── notifications/ # Notification system
|
||||
│ ├── search/ # Universal search
|
||||
│ ├── views/ # Saved views
|
||||
│ ├── admin/ # Admin functions
|
||||
│ ├── backup/ # Backup/restore (33KB, complex)
|
||||
│ ├── feature-flags/ # Feature flag management
|
||||
│ ├── quotes/ # Daily quotes
|
||||
│ └── url/ # URL handling
|
||||
│
|
||||
├── models/ # Sequelize model definitions
|
||||
│ ├── index.js # Model initialization & associations
|
||||
│ ├── task.js # Task model (recurrence fields)
|
||||
│ ├── project.js # Project model
|
||||
│ ├── area.js # Area model
|
||||
│ ├── note.js # Note model
|
||||
│ ├── tag.js # Tag model
|
||||
│ ├── user.js # User model (bcrypt password, settings)
|
||||
│ ├── permission.js # Permission/sharing model
|
||||
│ ├── apiToken.js # API token model
|
||||
│ ├── recurringCompletion.js
|
||||
│ ├── taskEvent.js # Task audit log
|
||||
│ ├── taskAttachment.js
|
||||
│ ├── inboxItem.js
|
||||
│ ├── notification.js
|
||||
│ ├── role.js
|
||||
│ ├── view.js
|
||||
│ ├── backup.js
|
||||
│ ├── setting.js
|
||||
│ └── action.js
|
||||
│
|
||||
├── migrations/ # Database migrations (64+ files)
|
||||
│ ├── 20240101120000-initial-schema.js
|
||||
│ ├── 20240115140000-add-recurring-tasks.js
|
||||
│ └── ... (timestamped migration files)
|
||||
│
|
||||
├── seeders/ # Database seed data
|
||||
│ └── (seed files if any)
|
||||
│
|
||||
├── middleware/ # Global middleware
|
||||
│ ├── auth.js # Authentication (session + Bearer token)
|
||||
│ ├── authorize.js # Authorization (permission checking)
|
||||
│ ├── rateLimiter.js # Rate limiting config (5 different limiters)
|
||||
│ ├── queryLogger.js # Development query logging
|
||||
│ └── permissionCache.js
|
||||
│
|
||||
├── services/ # Cross-cutting services
|
||||
│ ├── permissionsService.js # Main permissions service
|
||||
│ ├── backupService.js # Backup/restore operations
|
||||
│ ├── emailService.js # Email notifications
|
||||
│ ├── logService.js # Error logging
|
||||
│ ├── applyPerms.js # Apply permissions
|
||||
│ └── permissionsCalculators.js # Permission calculations
|
||||
│
|
||||
├── shared/ # Shared utilities
|
||||
│ ├── errors/ # Custom error classes
|
||||
│ │ ├── AppError.js
|
||||
│ │ ├── NotFoundError.js
|
||||
│ │ ├── ValidationError.js
|
||||
│ │ ├── ConflictError.js
|
||||
│ │ ├── UnauthorizedError.js
|
||||
│ │ └── ForbiddenError.js
|
||||
│ ├── middleware/
|
||||
│ │ └── errorHandler.js # Global error handler
|
||||
│ └── database/
|
||||
│ └── BaseRepository.js # Base repository class
|
||||
│
|
||||
├── utils/ # Utility functions
|
||||
│ ├── uid.js # Generate 15-char unique IDs (nanoid)
|
||||
│ ├── slug-utils.js # URL slug handling, UID extraction
|
||||
│ ├── timezone-utils.js # Timezone conversions, date calculations
|
||||
│ ├── attachment-utils.js # File handling and validation
|
||||
│ ├── migration-utils.js # Database migration helpers
|
||||
│ ├── request-utils.js # Request utilities
|
||||
│ └── notificationPreferences.js
|
||||
│
|
||||
├── config/ # Configuration
|
||||
│ ├── config.js # Environment-based config
|
||||
│ ├── database.js # Sequelize database config
|
||||
│ └── swagger.js # Swagger API schema (30KB)
|
||||
│
|
||||
├── docs/ # API documentation
|
||||
│ └── swagger/
|
||||
│ └── (swagger doc files)
|
||||
│
|
||||
├── scripts/ # Utility scripts
|
||||
│ └── (database management scripts)
|
||||
│
|
||||
└── tests/ # Backend tests
|
||||
├── unit/ # Unit tests
|
||||
│ ├── models/
|
||||
│ │ ├── task.test.js
|
||||
│ │ ├── project.test.js
|
||||
│ │ ├── user.test.js
|
||||
│ │ └── ...
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.test.js
|
||||
│ │ └── authorize.test.js
|
||||
│ ├── services/
|
||||
│ │ ├── permissionsService.test.js
|
||||
│ │ └── applyPerms.test.js
|
||||
│ └── utils/
|
||||
│ ├── timezone-utils.test.js
|
||||
│ ├── slug-utils.test.js
|
||||
│ ├── attachment-utils.test.js
|
||||
│ └── migration-utils.test.js
|
||||
│
|
||||
└── integration/ # Integration tests (47+ test directories)
|
||||
├── tasks/
|
||||
│ ├── tasks.test.js
|
||||
│ ├── subtasks.test.js
|
||||
│ └── recurring.test.js
|
||||
├── projects/
|
||||
├── areas/
|
||||
├── notes/
|
||||
├── tags/
|
||||
├── auth/
|
||||
├── shares/
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Structure
|
||||
|
||||
```
|
||||
/Users/chris/c0deLab/ProjectLand/tududi/frontend/
|
||||
│
|
||||
├── index.tsx # React application entry point
|
||||
│ # - React root initialization
|
||||
│ # - i18n setup
|
||||
│ # - Dark mode initialization
|
||||
│ # - Service worker cleanup
|
||||
│
|
||||
├── App.tsx # Root component (13KB)
|
||||
│ # - Route definitions
|
||||
│ # - User authentication check
|
||||
│ # - Route protection
|
||||
│ # - Layout wrapper
|
||||
│
|
||||
├── Layout.tsx # Main layout wrapper (21KB)
|
||||
│ # - Sidebar integration
|
||||
│ # - Navigation
|
||||
│ # - Modal management
|
||||
│
|
||||
├── components/ # React components (feature-based)
|
||||
│ │
|
||||
│ ├── Task/ # Task-related components
|
||||
│ │ ├── TasksToday.tsx
|
||||
│ │ ├── TaskDetails.tsx
|
||||
│ │ ├── TaskForm.tsx
|
||||
│ │ ├── TaskItem.tsx
|
||||
│ │ ├── TaskList.tsx
|
||||
│ │ ├── TaskFilters.tsx
|
||||
│ │ ├── SubtaskList.tsx
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── Project/ # Project components
|
||||
│ │ ├── ProjectDetails.tsx
|
||||
│ │ ├── ProjectForm.tsx
|
||||
│ │ ├── ProjectList.tsx
|
||||
│ │ ├── ProjectCard.tsx
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── Area/ # Area components
|
||||
│ │ ├── AreaDetails.tsx
|
||||
│ │ ├── AreaForm.tsx
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── Note/ # Note components
|
||||
│ │ ├── NoteDetails.tsx
|
||||
│ │ ├── NoteForm.tsx
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── Tag/ # Tag components
|
||||
│ ├── Habits/ # Recurring tasks UI
|
||||
│ ├── Inbox/ # Inbox management
|
||||
│ │
|
||||
│ ├── Calendar/ # Calendar view (27KB)
|
||||
│ │ └── Calendar.tsx
|
||||
│ │
|
||||
│ ├── Sidebar.tsx # Left navigation sidebar
|
||||
│ ├── Navbar.tsx # Top navigation bar
|
||||
│ │
|
||||
│ ├── Metrics/ # Productivity metrics
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── Notifications/ # Notification system
|
||||
│ ├── UniversalSearch/ # Search interface
|
||||
│ │
|
||||
│ ├── Shared/ # Shared UI components (41 items)
|
||||
│ │ ├── Modal components
|
||||
│ │ │ ├── Modal.tsx
|
||||
│ │ │ ├── ConfirmDialog.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── Form inputs
|
||||
│ │ │ ├── Input.tsx
|
||||
│ │ │ ├── Select.tsx
|
||||
│ │ │ ├── DatePicker.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ ├── ToastContext.tsx
|
||||
│ │ ├── LoadingScreen.tsx
|
||||
│ │ ├── Button.tsx
|
||||
│ │ ├── Badge.tsx
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── Admin/ # Admin panel
|
||||
│ ├── Backup/ # Backup/restore UI
|
||||
│ ├── Profile/ # User profile settings
|
||||
│ │ ├── ProfileSettings.tsx
|
||||
│ │ ├── ApiTokens.tsx
|
||||
│ │ └── ...
|
||||
│ ├── Productivity/ # Analytics dashboard
|
||||
│ └── Login/Register # Auth pages
|
||||
│ ├── Login.tsx
|
||||
│ └── Register.tsx
|
||||
│
|
||||
├── store/ # Zustand state management
|
||||
│ └── useStore.ts # Global store (28KB)
|
||||
│ # - Task state & cache
|
||||
│ # - Project state & cache
|
||||
│ # - UI state (modals, filters, selections)
|
||||
│ # - Cache management functions
|
||||
│
|
||||
├── contexts/ # React contexts
|
||||
│ ├── ModalContext.tsx # Modal state management
|
||||
│ ├── SidebarContext.tsx # Sidebar state
|
||||
│ └── TelegramStatusContext.tsx # Telegram integration status
|
||||
│
|
||||
├── hooks/ # Custom React hooks
|
||||
│ ├── useKeyboardShortcuts.ts # Keyboard handling
|
||||
│ ├── useModalManager.ts # Modal management
|
||||
│ ├── usePersistedModal.ts # Modal persistence
|
||||
│ └── useTasksData.ts # Task data fetching
|
||||
│
|
||||
├── utils/ # Frontend utilities (30+ files)
|
||||
│ ├── API Services (API client utilities)
|
||||
│ │ ├── tasksService.ts # Task API client
|
||||
│ │ ├── projectsService.ts # Project API client
|
||||
│ │ ├── notesService.ts
|
||||
│ │ ├── tagsService.ts
|
||||
│ │ ├── areasService.ts
|
||||
│ │ ├── profileService.ts # User profile API
|
||||
│ │ ├── apiKeysService.ts # API token management
|
||||
│ │ ├── searchService.ts # Search API client
|
||||
│ │ ├── sharesService.ts # Project sharing API
|
||||
│ │ ├── backupService.ts # Backup/restore API
|
||||
│ │ ├── inboxService.ts # Inbox API
|
||||
│ │ ├── habitsService.ts # Habits/recurring API
|
||||
│ │ ├── taskEventService.ts # Task history API
|
||||
│ │ ├── taskIntelligenceService.ts # AI-assisted task mgmt
|
||||
│ │ └── attachmentsService.ts # File attachment handling
|
||||
│ │
|
||||
│ ├── Utilities
|
||||
│ │ ├── dateUtils.ts # Date/time helpers
|
||||
│ │ ├── timezoneUtils.ts # Timezone handling
|
||||
│ │ ├── taskSortUtils.ts # Task sorting logic
|
||||
│ │ ├── localeUtils.ts # i18n helpers
|
||||
│ │ ├── keyboardShortcutsService.ts # Shortcut definitions
|
||||
│ │ ├── bannersService.ts # Banner management
|
||||
│ │ ├── urlService.ts # URL parsing
|
||||
│ │ ├── slugUtils.ts # URL slug handling
|
||||
│ │ ├── userUtils.ts # User utilities
|
||||
│ │ ├── fetcher.ts # SWR fetcher configuration
|
||||
│ │ └── featureFlags.ts # Feature flag client
|
||||
│ │
|
||||
│ └── config/
|
||||
│ └── paths.ts # API and path configuration
|
||||
│
|
||||
├── entities/ # TypeScript interfaces/types
|
||||
│ ├── Task.ts # Task type definition
|
||||
│ ├── Project.ts # Project type definition
|
||||
│ ├── Note.ts # Note type definition
|
||||
│ ├── User.ts # User type definition
|
||||
│ ├── Tag.ts # Tag type definition
|
||||
│ ├── Area.ts # Area type definition
|
||||
│ ├── TaskEvent.ts # Task event type
|
||||
│ ├── Attachment.ts # Attachment type
|
||||
│ ├── InboxItem.ts # Inbox item type
|
||||
│ └── Metrics.ts # Metrics type
|
||||
│
|
||||
├── i18n.ts # i18next configuration
|
||||
│ # - Language detection
|
||||
│ # - Resource loading
|
||||
│ # - 24 language support
|
||||
│
|
||||
├── styles/ # Global styles
|
||||
│ ├── globals.css
|
||||
│ ├── markdown.css
|
||||
│ └── ...
|
||||
│
|
||||
└── __tests__/ # Frontend tests
|
||||
├── setup.ts # Test configuration
|
||||
└── (component tests)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E Tests Structure
|
||||
|
||||
```
|
||||
/Users/chris/c0deLab/ProjectLand/tududi/e2e/
|
||||
├── tests/ # Playwright test specs
|
||||
│ ├── login.spec.ts
|
||||
│ ├── tasks.spec.ts
|
||||
│ ├── projects.spec.ts
|
||||
│ ├── subtasks.spec.ts
|
||||
│ ├── recurring-tasks.spec.ts
|
||||
│ └── ...
|
||||
└── bin/
|
||||
└── run-e2e.sh # Test runner script
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Paths Reference
|
||||
|
||||
Quick lookup table for common development tasks:
|
||||
|
||||
| Task | Primary Location | Related Files |
|
||||
|------|------------------|---------------|
|
||||
| **Add backend feature** | `/backend/modules/[feature]/` | routes.js, repository.js, operations/ |
|
||||
| **Create new model** | `/backend/models/[model].js` | Also update `/backend/models/index.js` for associations |
|
||||
| **Database migration** | `/backend/migrations/TIMESTAMP-name.js` | Create with `npm run migration:create` |
|
||||
| **Add React component** | `/frontend/components/[Feature]/ComponentName.tsx` | - |
|
||||
| **Define API routes** | `/backend/modules/[module]/routes.js` | - |
|
||||
| **Business logic** | `/backend/modules/[module]/operations/` | Or service files in module |
|
||||
| **Global frontend state** | `/frontend/store/useStore.ts` | Zustand store |
|
||||
| **API client** | `/frontend/utils/[resource]Service.ts` | - |
|
||||
| **TypeScript types** | `/frontend/entities/[Type].ts` | Interface definitions |
|
||||
| **Backend unit tests** | `/backend/tests/unit/[category]/` | models/, middleware/, services/, utils/ |
|
||||
| **Backend integration tests** | `/backend/tests/integration/[module]/` | - |
|
||||
| **E2E tests** | `/e2e/tests/[feature].spec.ts` | Playwright specs |
|
||||
| **Middleware** | `/backend/middleware/[name].js` | auth.js, authorize.js, etc. |
|
||||
| **Shared utilities** | `/backend/utils/` or `/frontend/utils/` | Depends on context |
|
||||
| **Error classes** | `/backend/shared/errors/` | Custom error types |
|
||||
| **Swagger docs** | `/backend/config/swagger.js` | API schema definitions |
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
526
docs/testing.md
Normal file
526
docs/testing.md
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
# Testing Requirements
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Test Organization
|
||||
|
||||
```
|
||||
/backend/tests/
|
||||
├── unit/ # Unit tests for isolated logic
|
||||
│ ├── models/ # Model tests
|
||||
│ │ ├── task.test.js
|
||||
│ │ ├── project.test.js
|
||||
│ │ ├── user.test.js
|
||||
│ │ └── ...
|
||||
│ ├── middleware/ # Middleware tests
|
||||
│ │ ├── auth.test.js
|
||||
│ │ └── authorize.test.js
|
||||
│ ├── services/ # Service tests
|
||||
│ │ ├── permissionsService.test.js
|
||||
│ │ ├── applyPerms.test.js
|
||||
│ │ └── ...
|
||||
│ └── utils/ # Utility tests
|
||||
│ ├── timezone-utils.test.js
|
||||
│ ├── slug-utils.test.js
|
||||
│ ├── attachment-utils.test.js
|
||||
│ └── migration-utils.test.js
|
||||
│
|
||||
└── integration/ # Integration tests for API endpoints
|
||||
├── tasks/
|
||||
│ ├── tasks.test.js
|
||||
│ ├── subtasks.test.js
|
||||
│ └── recurring.test.js
|
||||
├── projects/
|
||||
│ └── projects.test.js
|
||||
├── areas/
|
||||
├── notes/
|
||||
├── tags/
|
||||
├── auth/
|
||||
├── shares/
|
||||
└── ... (47+ test directories)
|
||||
|
||||
/e2e/tests/ # E2E tests (Playwright)
|
||||
├── login.spec.ts
|
||||
├── tasks.spec.ts
|
||||
├── projects.spec.ts
|
||||
├── subtasks.spec.ts
|
||||
└── ...
|
||||
|
||||
/frontend/__tests__/ # Frontend tests
|
||||
├── setup.ts # Test configuration
|
||||
└── components/
|
||||
└── ... (component tests)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Backend Tests
|
||||
|
||||
```bash
|
||||
# Run all backend tests
|
||||
npm test
|
||||
# or
|
||||
npm run backend:test
|
||||
|
||||
# Run specific test file
|
||||
npm test -- backend/tests/unit/models/task.test.js
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Watch mode (re-run on file changes)
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
```bash
|
||||
# Run frontend tests
|
||||
npm run frontend:test
|
||||
|
||||
# Watch mode
|
||||
npm run frontend:test -- --watch
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
|
||||
```bash
|
||||
# Headless mode (default)
|
||||
npm run test:ui
|
||||
|
||||
# Headed mode (see browser)
|
||||
npm run test:ui:headed
|
||||
|
||||
# Specific test file
|
||||
npx playwright test e2e/tests/tasks.spec.ts
|
||||
|
||||
# Debug mode
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
### Pre-Push Checks
|
||||
|
||||
```bash
|
||||
# Run all checks before committing/pushing
|
||||
npm run pre-push
|
||||
|
||||
# This runs:
|
||||
# - ESLint checks
|
||||
# - Prettier formatting
|
||||
# - Backend tests
|
||||
# - Type checking (if applicable)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### For Bug Fixes
|
||||
|
||||
**MUST include a test** that would have caught the bug.
|
||||
|
||||
**Process:**
|
||||
1. Write failing test that demonstrates the bug
|
||||
2. Fix the bug
|
||||
3. Verify test now passes
|
||||
4. Submit PR with both test and fix
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
// Test for bug: completed tasks showing in Today view
|
||||
it('should not return completed tasks in Today view', async () => {
|
||||
// Arrange - Create completed task
|
||||
await Task.create({
|
||||
name: 'Completed Task',
|
||||
status: 2, // completed
|
||||
due_date: new Date().toISOString().split('T')[0],
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
// Act - Get today's tasks
|
||||
const response = await request(app)
|
||||
.get('/api/v1/tasks/today')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
// Assert - No completed tasks
|
||||
expect(response.status).toBe(200);
|
||||
const completedTasks = response.body.filter(t => t.status === 2);
|
||||
expect(completedTasks.length).toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
### For New Features
|
||||
|
||||
**SHOULD include relevant tests** covering:
|
||||
- Happy path (success case)
|
||||
- Common edge cases
|
||||
- Error conditions
|
||||
|
||||
**Not required to test:**
|
||||
- Every possible combination
|
||||
- Framework internals
|
||||
- Third-party library behavior
|
||||
|
||||
---
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Backend Integration Test
|
||||
|
||||
**Arrange-Act-Assert Pattern:**
|
||||
|
||||
```javascript
|
||||
// /backend/tests/integration/tasks/tasks.test.js
|
||||
const request = require('supertest');
|
||||
const app = require('../../../app');
|
||||
const { Task, User } = require('../../../models');
|
||||
|
||||
describe('Task API', () => {
|
||||
let user;
|
||||
let authCookie;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Setup: Create user and authenticate
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/login')
|
||||
.send({ email: 'test@example.com', password: 'password123' });
|
||||
authCookie = res.headers['set-cookie'];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup
|
||||
await Task.destroy({ where: {} });
|
||||
await User.destroy({ where: {} });
|
||||
});
|
||||
|
||||
it('should create task with valid data', async () => {
|
||||
// Arrange
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
priority: 1,
|
||||
due_date: '2026-03-15'
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = await request(app)
|
||||
.post('/api/v1/task')
|
||||
.set('Cookie', authCookie)
|
||||
.send(taskData);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe('Test Task');
|
||||
expect(response.body.priority).toBe(1);
|
||||
|
||||
// Verify in database
|
||||
const task = await Task.findOne({ where: { name: 'Test Task' } });
|
||||
expect(task).not.toBeNull();
|
||||
expect(task.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should return 400 for missing name', async () => {
|
||||
// Arrange
|
||||
const invalidData = { priority: 1 };
|
||||
|
||||
// Act
|
||||
const response = await request(app)
|
||||
.post('/api/v1/task')
|
||||
.set('Cookie', authCookie)
|
||||
.send(invalidData);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
// Act
|
||||
const response = await request(app)
|
||||
.get('/api/v1/task/99999')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Backend Unit Test
|
||||
|
||||
```javascript
|
||||
// /backend/tests/unit/utils/timezone-utils.test.js
|
||||
const { getTodayBoundsInUTC } = require('../../../utils/timezone-utils');
|
||||
|
||||
describe('timezone-utils', () => {
|
||||
describe('getTodayBoundsInUTC', () => {
|
||||
it('should return UTC bounds for today in given timezone', () => {
|
||||
// Arrange
|
||||
const timezone = 'America/New_York';
|
||||
|
||||
// Act
|
||||
const { startOfDay, endOfDay } = getTodayBoundsInUTC(timezone);
|
||||
|
||||
// Assert
|
||||
expect(startOfDay).toBeInstanceOf(Date);
|
||||
expect(endOfDay).toBeInstanceOf(Date);
|
||||
expect(endOfDay.getTime()).toBeGreaterThan(startOfDay.getTime());
|
||||
});
|
||||
|
||||
it('should handle invalid timezone gracefully', () => {
|
||||
// Arrange
|
||||
const invalidTimezone = 'Invalid/Timezone';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => getTodayBoundsInUTC(invalidTimezone)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Frontend Component Test
|
||||
|
||||
```typescript
|
||||
// /frontend/components/Task/__tests__/TaskItem.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { TaskItem } from '../TaskItem';
|
||||
import { Task } from '../../../entities/Task';
|
||||
|
||||
describe('TaskItem', () => {
|
||||
const mockTask: Task = {
|
||||
id: 1,
|
||||
uid: 'test-uid-123',
|
||||
name: 'Test Task',
|
||||
completed: false,
|
||||
priority: 1,
|
||||
due_date: '2026-03-15'
|
||||
};
|
||||
|
||||
it('renders task name', () => {
|
||||
// Act
|
||||
render(<TaskItem task={mockTask} onUpdate={jest.fn()} />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Task')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows priority badge', () => {
|
||||
// Act
|
||||
render(<TaskItem task={mockTask} onUpdate={jest.fn()} />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Medium')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onUpdate when checkbox is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnUpdate = jest.fn();
|
||||
render(<TaskItem task={mockTask} onUpdate={mockOnUpdate} />);
|
||||
|
||||
// Act
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Assert
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith({
|
||||
...mockTask,
|
||||
completed: true
|
||||
});
|
||||
});
|
||||
|
||||
it('applies completed styling when task is done', () => {
|
||||
// Arrange
|
||||
const completedTask = { ...mockTask, completed: true };
|
||||
|
||||
// Act
|
||||
render(<TaskItem task={completedTask} onUpdate={jest.fn()} />);
|
||||
|
||||
// Assert
|
||||
const taskElement = screen.getByText('Test Task').closest('div');
|
||||
expect(taskElement).toHaveClass('line-through');
|
||||
expect(taskElement).toHaveClass('opacity-50');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E Test (Playwright)
|
||||
|
||||
```typescript
|
||||
// /e2e/tests/tasks.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Task Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login before each test
|
||||
await page.goto('http://localhost:8080/login');
|
||||
await page.fill('input[name="email"]', 'test@example.com');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('**/tasks');
|
||||
});
|
||||
|
||||
test('should create new task', async ({ page }) => {
|
||||
// Arrange
|
||||
await page.click('button:has-text("New Task")');
|
||||
|
||||
// Act
|
||||
await page.fill('input[name="name"]', 'E2E Test Task');
|
||||
await page.selectOption('select[name="priority"]', '1');
|
||||
await page.fill('input[name="due_date"]', '2026-03-15');
|
||||
await page.click('button:has-text("Save")');
|
||||
|
||||
// Assert
|
||||
await expect(page.locator('text=E2E Test Task')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should complete task', async ({ page }) => {
|
||||
// Arrange - Create a task first
|
||||
await page.click('button:has-text("New Task")');
|
||||
await page.fill('input[name="name"]', 'Task to Complete');
|
||||
await page.click('button:has-text("Save")');
|
||||
|
||||
// Act - Complete the task
|
||||
const taskItem = page.locator('text=Task to Complete').locator('..');
|
||||
await taskItem.locator('input[type="checkbox"]').check();
|
||||
|
||||
// Assert
|
||||
await expect(taskItem).toHaveClass(/line-through/);
|
||||
});
|
||||
|
||||
test('should filter tasks by priority', async ({ page }) => {
|
||||
// Arrange - Create tasks with different priorities
|
||||
await createTask(page, 'High Priority Task', 2);
|
||||
await createTask(page, 'Low Priority Task', 0);
|
||||
|
||||
// Act - Filter by high priority
|
||||
await page.selectOption('select[name="priority_filter"]', '2');
|
||||
|
||||
// Assert
|
||||
await expect(page.locator('text=High Priority Task')).toBeVisible();
|
||||
await expect(page.locator('text=Low Priority Task')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function createTask(page, name: string, priority: number) {
|
||||
await page.click('button:has-text("New Task")');
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.selectOption('select[name="priority"]', priority.toString());
|
||||
await page.click('button:has-text("Save")');
|
||||
await page.waitForSelector(`text=${name}`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Database
|
||||
|
||||
Backend tests use a separate test database:
|
||||
|
||||
- Automatically created in test environment
|
||||
- Migrations run before tests
|
||||
- Database cleared between tests (in `afterEach`)
|
||||
- Configured in `/backend/config/database.js`
|
||||
|
||||
**Example cleanup:**
|
||||
```javascript
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await Task.destroy({ where: {} });
|
||||
await Project.destroy({ where: {} });
|
||||
await User.destroy({ where: {} });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mocking
|
||||
|
||||
### Mock External Services
|
||||
|
||||
```javascript
|
||||
// Mock email service in tests
|
||||
jest.mock('../../../services/emailService', () => ({
|
||||
sendEmail: jest.fn().mockResolvedValue(true)
|
||||
}));
|
||||
|
||||
it('should send notification email', async () => {
|
||||
const emailService = require('../../../services/emailService');
|
||||
|
||||
await taskService.create({ name: 'Task', notify: true }, userId);
|
||||
|
||||
expect(emailService.sendEmail).toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Frontend API Calls
|
||||
|
||||
```typescript
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('/api/v1/tasks', (req, res, ctx) => {
|
||||
return res(ctx.json([
|
||||
{ id: 1, name: 'Mocked Task' }
|
||||
]));
|
||||
})
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen());
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
While not strictly enforced, aim for:
|
||||
- **Critical paths:** 80%+ coverage
|
||||
- **Business logic:** 70%+ coverage
|
||||
- **UI components:** 50%+ coverage
|
||||
|
||||
**Run coverage report:**
|
||||
```bash
|
||||
npm run test:coverage
|
||||
|
||||
# Open HTML report
|
||||
open coverage/index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Before Submitting PR
|
||||
|
||||
✅ All tests passing:
|
||||
```bash
|
||||
npm test
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
✅ No linting errors:
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
✅ Code formatted:
|
||||
```bash
|
||||
npm run format:fix
|
||||
```
|
||||
|
||||
✅ Run pre-push checks:
|
||||
```bash
|
||||
npm run pre-push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[← Back to Index](../CLAUDE.md)
|
||||
Loading…
Add table
Add a link
Reference in a new issue