Linting cleanup (#99)
* Add eslint and prettier dependencies and configs * Lint project.
This commit is contained in:
parent
dd6ec117d0
commit
e594d1075b
97 changed files with 26554 additions and 39840 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
.idea
|
||||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
|
|
@ -27,4 +28,4 @@ dist/
|
|||
build/
|
||||
|
||||
# Webpack output
|
||||
public/assets/
|
||||
public/assets/
|
||||
|
|
|
|||
6
backend/.prettierrc.json
Normal file
6
backend/.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
227
backend/app.js
227
backend/app.js
|
|
@ -15,64 +15,91 @@ const app = express();
|
|||
|
||||
// Session store
|
||||
const sessionStore = new SequelizeStore({
|
||||
db: sequelize,
|
||||
db: sequelize,
|
||||
});
|
||||
|
||||
// Middlewares
|
||||
const sslEnabled = process.env.NODE_ENV === 'production' && process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
|
||||
app.use(helmet({
|
||||
hsts: sslEnabled, // Only enable HSTS when SSL is enabled
|
||||
forceHTTPS: sslEnabled, // Only force HTTPS when SSL is enabled
|
||||
contentSecurityPolicy: false // Disable CSP for now to avoid conflicts
|
||||
}));
|
||||
const sslEnabled =
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
|
||||
app.use(
|
||||
helmet({
|
||||
hsts: sslEnabled, // Only enable HSTS when SSL is enabled
|
||||
forceHTTPS: sslEnabled, // Only force HTTPS when SSL is enabled
|
||||
contentSecurityPolicy: false, // Disable CSP for now to avoid conflicts
|
||||
})
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(morgan('combined'));
|
||||
|
||||
// CORS configuration
|
||||
const allowedOrigins = process.env.TUDUDI_ALLOWED_ORIGINS
|
||||
? process.env.TUDUDI_ALLOWED_ORIGINS.split(',').map(origin => origin.trim())
|
||||
: ['http://localhost:8080', 'http://localhost:9292', 'http://127.0.0.1:8080', 'http://127.0.0.1:9292'];
|
||||
const allowedOrigins = process.env.TUDUDI_ALLOWED_ORIGINS
|
||||
? process.env.TUDUDI_ALLOWED_ORIGINS.split(',').map((origin) =>
|
||||
origin.trim()
|
||||
)
|
||||
: [
|
||||
'http://localhost:8080',
|
||||
'http://localhost:9292',
|
||||
'http://127.0.0.1:8080',
|
||||
'http://127.0.0.1:9292',
|
||||
];
|
||||
|
||||
app.use(cors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'Accept', 'X-Requested-With'],
|
||||
exposedHeaders: ['Content-Type'],
|
||||
maxAge: 1728000
|
||||
}));
|
||||
app.use(
|
||||
cors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Authorization',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'X-Requested-With',
|
||||
],
|
||||
exposedHeaders: ['Content-Type'],
|
||||
maxAge: 1728000,
|
||||
})
|
||||
);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Session configuration
|
||||
const secureFlag = process.env.NODE_ENV === 'production' && process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
|
||||
app.use(session({
|
||||
secret: process.env.TUDUDI_SESSION_SECRET || require('crypto').randomBytes(64).toString('hex'),
|
||||
store: sessionStore,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: secureFlag,
|
||||
maxAge: 2592000000, // 30 days
|
||||
sameSite: secureFlag ? 'none' : 'lax'
|
||||
}
|
||||
}));
|
||||
const secureFlag =
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
|
||||
app.use(
|
||||
session({
|
||||
secret:
|
||||
process.env.TUDUDI_SESSION_SECRET ||
|
||||
require('crypto').randomBytes(64).toString('hex'),
|
||||
store: sessionStore,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: secureFlag,
|
||||
maxAge: 2592000000, // 30 days
|
||||
sameSite: secureFlag ? 'none' : 'lax',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Static files
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
} else {
|
||||
app.use(express.static('public'));
|
||||
app.use(express.static('public'));
|
||||
}
|
||||
|
||||
// Serve locales
|
||||
// Serve locales
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use('/locales', express.static(path.join(__dirname, 'dist/locales')));
|
||||
app.use('/locales', express.static(path.join(__dirname, 'dist/locales')));
|
||||
} else {
|
||||
app.use('/locales', express.static(path.join(__dirname, '../public/locales')));
|
||||
app.use(
|
||||
'/locales',
|
||||
express.static(path.join(__dirname, '../public/locales'))
|
||||
);
|
||||
}
|
||||
|
||||
// Serve uploaded files
|
||||
|
|
@ -83,12 +110,12 @@ const { requireAuth } = require('./middleware/auth');
|
|||
|
||||
// Health check (before auth middleware) - ensure it's completely bypassed
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
|
|
@ -108,74 +135,86 @@ app.use('/api/calendar', require('./routes/calendar'));
|
|||
|
||||
// SPA fallback
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api/') && !req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
if (
|
||||
!req.path.startsWith('/api/') &&
|
||||
!req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)
|
||||
) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
} else {
|
||||
res.sendFile(path.join(__dirname, '../public', 'index.html'));
|
||||
}
|
||||
} else {
|
||||
res.sendFile(path.join(__dirname, '../public', 'index.html'));
|
||||
res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: 'The requested resource could not be found.',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.status(404).json({ error: 'Not Found', message: 'The requested resource could not be found.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({ error: 'Internal Server Error', message: err.message });
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3002;
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Create session store table
|
||||
await sessionStore.sync();
|
||||
|
||||
// Sync database
|
||||
await sequelize.sync();
|
||||
|
||||
// Auto-create user if not exists
|
||||
if (process.env.TUDUDI_USER_EMAIL && process.env.TUDUDI_USER_PASSWORD) {
|
||||
const { User } = require('./models');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const [user, created] = await User.findOrCreate({
|
||||
where: { email: process.env.TUDUDI_USER_EMAIL },
|
||||
defaults: {
|
||||
email: process.env.TUDUDI_USER_EMAIL,
|
||||
password_digest: await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD, 10)
|
||||
try {
|
||||
// Create session store table
|
||||
await sessionStore.sync();
|
||||
|
||||
// Sync database
|
||||
await sequelize.sync();
|
||||
|
||||
// Auto-create user if not exists
|
||||
if (process.env.TUDUDI_USER_EMAIL && process.env.TUDUDI_USER_PASSWORD) {
|
||||
const { User } = require('./models');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const [user, created] = await User.findOrCreate({
|
||||
where: { email: process.env.TUDUDI_USER_EMAIL },
|
||||
defaults: {
|
||||
email: process.env.TUDUDI_USER_EMAIL,
|
||||
password_digest: await bcrypt.hash(
|
||||
process.env.TUDUDI_USER_PASSWORD,
|
||||
10
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
if (created) {
|
||||
console.log('Default user created:', user.email);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (created) {
|
||||
console.log('Default user created:', user.email);
|
||||
}
|
||||
|
||||
// Initialize Telegram polling after database is ready
|
||||
await initializeTelegramPolling();
|
||||
|
||||
// Initialize task scheduler
|
||||
await taskScheduler.initialize();
|
||||
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`Server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('Server error:', err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Initialize Telegram polling after database is ready
|
||||
await initializeTelegramPolling();
|
||||
|
||||
// Initialize task scheduler
|
||||
await taskScheduler.initialize();
|
||||
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`Server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('Server error:', err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startServer();
|
||||
startServer();
|
||||
}
|
||||
|
||||
module.exports = app;
|
||||
module.exports = app;
|
||||
|
|
|
|||
|
|
@ -1,42 +1,42 @@
|
|||
require('dotenv').config();
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = process.env.DATABASE_URL
|
||||
? process.env.DATABASE_URL.replace('sqlite:///', '')
|
||||
: path.join(__dirname, '..', 'db');
|
||||
const dbPath = process.env.DATABASE_URL
|
||||
? process.env.DATABASE_URL.replace('sqlite:///', '')
|
||||
: path.join(__dirname, '..', 'db');
|
||||
|
||||
module.exports = {
|
||||
development: {
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dbPath, 'development.sqlite3'),
|
||||
logging: console.log,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
},
|
||||
test: {
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dbPath, 'test.sqlite3'),
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
},
|
||||
production: {
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dbPath, 'production.sqlite3'),
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
}
|
||||
};
|
||||
development: {
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dbPath, 'development.sqlite3'),
|
||||
logging: console.log,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dbPath, 'test.sqlite3'),
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
},
|
||||
production: {
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dbPath, 'production.sqlite3'),
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,21 @@
|
|||
quotes:
|
||||
- "Believe you can and you're halfway there."
|
||||
- "The only way to do great work is to love what you do."
|
||||
- "Success is not final, failure is not fatal: It is the courage to continue that counts."
|
||||
- "It always seems impossible until it's done."
|
||||
- "Your time is limited, don't waste it living someone else's life."
|
||||
- "The future belongs to those who believe in the beauty of their dreams."
|
||||
- "Don't watch the clock; do what it does. Keep going."
|
||||
- "Quality is not an act, it is a habit."
|
||||
- "The only limit to our realization of tomorrow is our doubts of today."
|
||||
- "Act as if what you do makes a difference. It does."
|
||||
- "The best way to predict the future is to create it."
|
||||
- "Success is walking from failure to failure with no loss of enthusiasm."
|
||||
- "You are never too old to set another goal or to dream a new dream."
|
||||
- "The secret of getting ahead is getting started."
|
||||
- "Don't let yesterday take up too much of today."
|
||||
- "You don't have to be great to start, but you have to start to be great."
|
||||
- "Focus on progress, not perfection."
|
||||
- "One task at a time leads to great accomplishments."
|
||||
- "Today's effort is tomorrow's success."
|
||||
- "Small steps every day lead to big results."
|
||||
|
||||
- "Believe you can and you're halfway there."
|
||||
- 'The only way to do great work is to love what you do.'
|
||||
- 'Success is not final, failure is not fatal: It is the courage to continue that counts.'
|
||||
- "It always seems impossible until it's done."
|
||||
- "Your time is limited, don't waste it living someone else's life."
|
||||
- 'The future belongs to those who believe in the beauty of their dreams.'
|
||||
- "Don't watch the clock; do what it does. Keep going."
|
||||
- 'Quality is not an act, it is a habit.'
|
||||
- 'The only limit to our realization of tomorrow is our doubts of today.'
|
||||
- 'Act as if what you do makes a difference. It does.'
|
||||
- 'The best way to predict the future is to create it.'
|
||||
- 'Success is walking from failure to failure with no loss of enthusiasm.'
|
||||
- 'You are never too old to set another goal or to dream a new dream.'
|
||||
- 'The secret of getting ahead is getting started.'
|
||||
- "Don't let yesterday take up too much of today."
|
||||
- "You don't have to be great to start, but you have to start to be great."
|
||||
- 'Focus on progress, not perfection.'
|
||||
- 'One task at a time leads to great accomplishments.'
|
||||
- "Today's effort is tomorrow's success."
|
||||
- 'Small steps every day lead to big results.'
|
||||
|
|
|
|||
40
backend/eslint.config.js
Normal file
40
backend/eslint.config.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
module.exports = [
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
require: 'readonly',
|
||||
module: 'readonly',
|
||||
exports: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
global: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
prettier: require('eslint-plugin-prettier'),
|
||||
jest: require('eslint-plugin-jest'),
|
||||
},
|
||||
rules: {
|
||||
...require('eslint-plugin-prettier').configs.recommended.rules,
|
||||
...require('eslint-plugin-jest').configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.js', '**/*.spec.js', 'tests/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...require('eslint-plugin-jest').environments.globals.globals,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1,25 +1,22 @@
|
|||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/helpers/setup.js'],
|
||||
testMatch: [
|
||||
'<rootDir>/tests/**/*.test.js',
|
||||
'<rootDir>/tests/**/*.spec.js'
|
||||
],
|
||||
maxWorkers: 1,
|
||||
collectCoverageFrom: [
|
||||
'routes/**/*.js',
|
||||
'models/**/*.js',
|
||||
'middleware/**/*.js',
|
||||
'services/**/*.js',
|
||||
'!models/index.js',
|
||||
'!**/*.test.js',
|
||||
'!**/*.spec.js'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
verbose: true,
|
||||
forceExit: true,
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
restoreMocks: true
|
||||
};
|
||||
testEnvironment: 'node',
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/helpers/setup.js'],
|
||||
testMatch: ['<rootDir>/tests/**/*.test.js', '<rootDir>/tests/**/*.spec.js'],
|
||||
maxWorkers: 1,
|
||||
collectCoverageFrom: [
|
||||
'routes/**/*.js',
|
||||
'models/**/*.js',
|
||||
'middleware/**/*.js',
|
||||
'services/**/*.js',
|
||||
'!models/index.js',
|
||||
'!**/*.test.js',
|
||||
'!**/*.spec.js',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
verbose: true,
|
||||
forceExit: true,
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
restoreMocks: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
const { User } = require('../models');
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
try {
|
||||
// Skip authentication for health check, login routes, and current_user
|
||||
const skipPaths = ['/api/health', '/api/login', '/api/current_user'];
|
||||
if (skipPaths.includes(req.path) || req.originalUrl === '/api/health') {
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
// Skip authentication for health check, login routes, and current_user
|
||||
const skipPaths = ['/api/health', '/api/login', '/api/current_user'];
|
||||
if (skipPaths.includes(req.path) || req.originalUrl === '/api/health') {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
req.session.destroy();
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
req.session.destroy();
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
req.currentUser = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
req.currentUser = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
requireAuth
|
||||
};
|
||||
requireAuth,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('users', {
|
||||
id: {
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
telegram_bot_token: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
telegram_chat_id: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
task_summary_enabled: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
task_summary_frequency: {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: 'daily'
|
||||
},
|
||||
task_summary_last_run: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
task_summary_next_run: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
},
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('users', {
|
||||
id: {
|
||||
allowNull: false,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
type: Sequelize.INTEGER,
|
||||
},
|
||||
email: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
password: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
telegram_bot_token: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
telegram_chat_id: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
task_summary_enabled: {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
task_summary_frequency: {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: 'daily',
|
||||
},
|
||||
task_summary_last_run: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
task_summary_next_run: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('users');
|
||||
}
|
||||
};
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('users');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,47 +1,57 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add new fields to support enhanced recurring task functionality
|
||||
await queryInterface.addColumn('tasks', 'recurrence_weekday', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) for weekly recurrence'
|
||||
});
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add new fields to support enhanced recurring task functionality
|
||||
await queryInterface.addColumn('tasks', 'recurrence_weekday', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment:
|
||||
'Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) for weekly recurrence',
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('tasks', 'recurrence_month_day', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Day of month (1-31) for monthly recurrence, -1 for last day'
|
||||
});
|
||||
await queryInterface.addColumn('tasks', 'recurrence_month_day', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment:
|
||||
'Day of month (1-31) for monthly recurrence, -1 for last day',
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('tasks', 'recurrence_week_of_month', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Week of month (1-5) for monthly weekday recurrence'
|
||||
});
|
||||
await queryInterface.addColumn('tasks', 'recurrence_week_of_month', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Week of month (1-5) for monthly weekday recurrence',
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('tasks', 'completion_based', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: 'Whether recurrence is based on completion date (true) or due date (false)'
|
||||
});
|
||||
await queryInterface.addColumn('tasks', 'completion_based', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment:
|
||||
'Whether recurrence is based on completion date (true) or due date (false)',
|
||||
});
|
||||
|
||||
// Add index for efficient recurring task queries
|
||||
await queryInterface.addIndex('tasks', ['recurrence_type', 'last_generated_date'], {
|
||||
name: 'idx_tasks_recurrence_lookup'
|
||||
});
|
||||
},
|
||||
// Add index for efficient recurring task queries
|
||||
await queryInterface.addIndex(
|
||||
'tasks',
|
||||
['recurrence_type', 'last_generated_date'],
|
||||
{
|
||||
name: 'idx_tasks_recurrence_lookup',
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the added columns
|
||||
await queryInterface.removeColumn('tasks', 'recurrence_weekday');
|
||||
await queryInterface.removeColumn('tasks', 'recurrence_month_day');
|
||||
await queryInterface.removeColumn('tasks', 'recurrence_week_of_month');
|
||||
await queryInterface.removeColumn('tasks', 'completion_based');
|
||||
|
||||
// Remove the index
|
||||
await queryInterface.removeIndex('tasks', 'idx_tasks_recurrence_lookup');
|
||||
}
|
||||
};
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the added columns
|
||||
await queryInterface.removeColumn('tasks', 'recurrence_weekday');
|
||||
await queryInterface.removeColumn('tasks', 'recurrence_month_day');
|
||||
await queryInterface.removeColumn('tasks', 'recurrence_week_of_month');
|
||||
await queryInterface.removeColumn('tasks', 'completion_based');
|
||||
|
||||
// Remove the index
|
||||
await queryInterface.removeIndex(
|
||||
'tasks',
|
||||
'idx_tasks_recurrence_lookup'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('tasks', 'recurring_parent_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL'
|
||||
});
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('tasks', 'recurring_parent_id', {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
|
||||
// Add index for performance
|
||||
await queryInterface.addIndex('tasks', ['recurring_parent_id']);
|
||||
},
|
||||
// Add index for performance
|
||||
await queryInterface.addIndex('tasks', ['recurring_parent_id']);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeIndex('tasks', ['recurring_parent_id']);
|
||||
await queryInterface.removeColumn('tasks', 'recurring_parent_id');
|
||||
}
|
||||
};
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeIndex('tasks', ['recurring_parent_id']);
|
||||
await queryInterface.removeColumn('tasks', 'recurring_parent_id');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('projects', 'image_url', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
});
|
||||
},
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('projects', 'image_url', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('projects', 'image_url');
|
||||
}
|
||||
};
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('projects', 'image_url');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('users', 'task_intelligence_enabled', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
},
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('users', 'task_intelligence_enabled', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('users', 'task_intelligence_enabled');
|
||||
}
|
||||
};
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('users', 'task_intelligence_enabled');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('users', 'auto_suggest_next_actions_enabled', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
});
|
||||
},
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn(
|
||||
'users',
|
||||
'auto_suggest_next_actions_enabled',
|
||||
{
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('users', 'auto_suggest_next_actions_enabled');
|
||||
}
|
||||
};
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn(
|
||||
'users',
|
||||
'auto_suggest_next_actions_enabled'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add completed_at column to tasks table
|
||||
await queryInterface.addColumn('tasks', 'completed_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
});
|
||||
|
||||
// Add an index for better query performance
|
||||
await queryInterface.addIndex('tasks', ['completed_at']);
|
||||
},
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add completed_at column to tasks table
|
||||
await queryInterface.addColumn('tasks', 'completed_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
});
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the index first
|
||||
await queryInterface.removeIndex('tasks', ['completed_at']);
|
||||
|
||||
// Remove the completed_at column
|
||||
await queryInterface.removeColumn('tasks', 'completed_at');
|
||||
}
|
||||
};
|
||||
// Add an index for better query performance
|
||||
await queryInterface.addIndex('tasks', ['completed_at']);
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the index first
|
||||
await queryInterface.removeIndex('tasks', ['completed_at']);
|
||||
|
||||
// Remove the completed_at column
|
||||
await queryInterface.removeColumn('tasks', 'completed_at');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,79 +1,79 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('calendar_tokens', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
provider: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'google'
|
||||
},
|
||||
access_token: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
refresh_token: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
token_type: {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: 'Bearer'
|
||||
},
|
||||
expires_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
scope: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
connected_email: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('calendar_tokens', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
provider: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'google',
|
||||
},
|
||||
access_token: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
refresh_token: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
token_type: {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: 'Bearer',
|
||||
},
|
||||
expires_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
scope: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
connected_email: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
});
|
||||
|
||||
// Add unique index for user_id + provider combination
|
||||
await queryInterface.addIndex('calendar_tokens', {
|
||||
fields: ['user_id', 'provider'],
|
||||
unique: true,
|
||||
name: 'calendar_tokens_user_provider_unique'
|
||||
});
|
||||
// Add unique index for user_id + provider combination
|
||||
await queryInterface.addIndex('calendar_tokens', {
|
||||
fields: ['user_id', 'provider'],
|
||||
unique: true,
|
||||
name: 'calendar_tokens_user_provider_unique',
|
||||
});
|
||||
|
||||
// Add index for faster lookups by user_id
|
||||
await queryInterface.addIndex('calendar_tokens', {
|
||||
fields: ['user_id'],
|
||||
name: 'calendar_tokens_user_id_index'
|
||||
});
|
||||
},
|
||||
// Add index for faster lookups by user_id
|
||||
await queryInterface.addIndex('calendar_tokens', {
|
||||
fields: ['user_id'],
|
||||
name: 'calendar_tokens_user_id_index',
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('calendar_tokens');
|
||||
}
|
||||
};
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('calendar_tokens');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,88 +1,94 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Create task_events table
|
||||
await queryInterface.createTable('task_events', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
task_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
event_type: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
// Common event types: 'created', 'status_changed', 'priority_changed',
|
||||
// 'due_date_changed', 'project_changed', 'name_changed', 'description_changed',
|
||||
// 'completed', 'archived', 'deleted', 'restored'
|
||||
},
|
||||
old_value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// JSON string of the old value(s) - for tracking what changed from
|
||||
},
|
||||
new_value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// JSON string of the new value(s) - for tracking what changed to
|
||||
},
|
||||
field_name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
// The name of the field that was changed (status, priority, due_date, etc.)
|
||||
},
|
||||
metadata: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// Additional context as JSON string (e.g., source of change: 'web', 'api', 'telegram')
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Create task_events table
|
||||
await queryInterface.createTable('task_events', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
task_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
event_type: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
// Common event types: 'created', 'status_changed', 'priority_changed',
|
||||
// 'due_date_changed', 'project_changed', 'name_changed', 'description_changed',
|
||||
// 'completed', 'archived', 'deleted', 'restored'
|
||||
},
|
||||
old_value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// JSON string of the old value(s) - for tracking what changed from
|
||||
},
|
||||
new_value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// JSON string of the new value(s) - for tracking what changed to
|
||||
},
|
||||
field_name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
// The name of the field that was changed (status, priority, due_date, etc.)
|
||||
},
|
||||
metadata: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// Additional context as JSON string (e.g., source of change: 'web', 'api', 'telegram')
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
});
|
||||
|
||||
// Add indexes for better query performance
|
||||
await queryInterface.addIndex('task_events', ['task_id']);
|
||||
await queryInterface.addIndex('task_events', ['user_id']);
|
||||
await queryInterface.addIndex('task_events', ['event_type']);
|
||||
await queryInterface.addIndex('task_events', ['created_at']);
|
||||
await queryInterface.addIndex('task_events', ['task_id', 'event_type']);
|
||||
await queryInterface.addIndex('task_events', ['task_id', 'created_at']);
|
||||
},
|
||||
// Add indexes for better query performance
|
||||
await queryInterface.addIndex('task_events', ['task_id']);
|
||||
await queryInterface.addIndex('task_events', ['user_id']);
|
||||
await queryInterface.addIndex('task_events', ['event_type']);
|
||||
await queryInterface.addIndex('task_events', ['created_at']);
|
||||
await queryInterface.addIndex('task_events', ['task_id', 'event_type']);
|
||||
await queryInterface.addIndex('task_events', ['task_id', 'created_at']);
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove indexes first
|
||||
await queryInterface.removeIndex('task_events', ['task_id', 'created_at']);
|
||||
await queryInterface.removeIndex('task_events', ['task_id', 'event_type']);
|
||||
await queryInterface.removeIndex('task_events', ['created_at']);
|
||||
await queryInterface.removeIndex('task_events', ['event_type']);
|
||||
await queryInterface.removeIndex('task_events', ['user_id']);
|
||||
await queryInterface.removeIndex('task_events', ['task_id']);
|
||||
|
||||
// Drop the table
|
||||
await queryInterface.dropTable('task_events');
|
||||
}
|
||||
};
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove indexes first
|
||||
await queryInterface.removeIndex('task_events', [
|
||||
'task_id',
|
||||
'created_at',
|
||||
]);
|
||||
await queryInterface.removeIndex('task_events', [
|
||||
'task_id',
|
||||
'event_type',
|
||||
]);
|
||||
await queryInterface.removeIndex('task_events', ['created_at']);
|
||||
await queryInterface.removeIndex('task_events', ['event_type']);
|
||||
await queryInterface.removeIndex('task_events', ['user_id']);
|
||||
await queryInterface.removeIndex('task_events', ['task_id']);
|
||||
|
||||
// Drop the table
|
||||
await queryInterface.dropTable('task_events');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('users', 'pomodoro_enabled', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
},
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('users', 'pomodoro_enabled', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('users', 'pomodoro_enabled');
|
||||
}
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('users', 'pomodoro_enabled');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,39 +3,39 @@
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add UUID column to tasks table (without unique constraint initially)
|
||||
await queryInterface.addColumn('tasks', 'uuid', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
});
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add UUID column to tasks table (without unique constraint initially)
|
||||
await queryInterface.addColumn('tasks', 'uuid', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
});
|
||||
|
||||
// Backfill existing tasks with UUIDs
|
||||
const tasks = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM tasks WHERE uuid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
// Backfill existing tasks with UUIDs
|
||||
const tasks = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM tasks WHERE uuid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const task of tasks) {
|
||||
const uuid = uuidv4();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE tasks SET uuid = ? WHERE id = ?',
|
||||
{ replacements: [uuid, task.id] }
|
||||
);
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const uuid = uuidv4();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE tasks SET uuid = ? WHERE id = ?',
|
||||
{ replacements: [uuid, task.id] }
|
||||
);
|
||||
}
|
||||
|
||||
// Add unique index for UUID
|
||||
await queryInterface.addIndex('tasks', ['uuid'], {
|
||||
unique: true,
|
||||
name: 'tasks_uuid_unique'
|
||||
});
|
||||
},
|
||||
// Add unique index for UUID
|
||||
await queryInterface.addIndex('tasks', ['uuid'], {
|
||||
unique: true,
|
||||
name: 'tasks_uuid_unique',
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove index first
|
||||
await queryInterface.removeIndex('tasks', 'tasks_uuid_unique');
|
||||
|
||||
// Remove UUID column
|
||||
await queryInterface.removeColumn('tasks', 'uuid');
|
||||
}
|
||||
};
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove index first
|
||||
await queryInterface.removeIndex('tasks', 'tasks_uuid_unique');
|
||||
|
||||
// Remove UUID column
|
||||
await queryInterface.removeColumn('tasks', 'uuid');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Check if notes_tags table exists
|
||||
const tables = await queryInterface.showAllTables();
|
||||
if (!tables.includes('notes_tags')) {
|
||||
await queryInterface.createTable('notes_tags', {
|
||||
note_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'notes',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
tag_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'tags',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Check if notes_tags table exists
|
||||
const tables = await queryInterface.showAllTables();
|
||||
if (!tables.includes('notes_tags')) {
|
||||
await queryInterface.createTable('notes_tags', {
|
||||
note_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'notes',
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
tag_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'tags',
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
});
|
||||
|
||||
// Add unique index
|
||||
await queryInterface.addIndex('notes_tags', ['note_id', 'tag_id'], {
|
||||
unique: true,
|
||||
name: 'notes_tags_unique_idx',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Add unique index
|
||||
await queryInterface.addIndex('notes_tags', ['note_id', 'tag_id'], {
|
||||
unique: true,
|
||||
name: 'notes_tags_unique_idx'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('notes_tags');
|
||||
}
|
||||
};
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('notes_tags');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add created_at and updated_at columns to notes_tags table
|
||||
try {
|
||||
await queryInterface.addColumn('notes_tags', 'created_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('notes_tags', 'updated_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
|
||||
console.log('Successfully added timestamps to notes_tags table');
|
||||
} catch (error) {
|
||||
console.error('Error adding timestamps to notes_tags:', error);
|
||||
}
|
||||
},
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add created_at and updated_at columns to notes_tags table
|
||||
try {
|
||||
await queryInterface.addColumn('notes_tags', 'created_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
});
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('notes_tags', 'created_at');
|
||||
await queryInterface.removeColumn('notes_tags', 'updated_at');
|
||||
}
|
||||
};
|
||||
await queryInterface.addColumn('notes_tags', 'updated_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
});
|
||||
|
||||
console.log('Successfully added timestamps to notes_tags table');
|
||||
} catch (error) {
|
||||
console.error('Error adding timestamps to notes_tags:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('notes_tags', 'created_at');
|
||||
await queryInterface.removeColumn('notes_tags', 'updated_at');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,82 +1,83 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Create the projects_tags table if it doesn't exist
|
||||
const tableExists = await queryInterface.showAllTables()
|
||||
.then(tables => tables.includes('projects_tags'));
|
||||
|
||||
if (!tableExists) {
|
||||
await queryInterface.createTable('projects_tags', {
|
||||
project_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'projects',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
tag_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tags',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
|
||||
// Add composite primary key
|
||||
await queryInterface.addConstraint('projects_tags', {
|
||||
fields: ['project_id', 'tag_id'],
|
||||
type: 'primary key',
|
||||
name: 'projects_tags_pkey'
|
||||
});
|
||||
} else {
|
||||
// Add timestamps if table exists but doesn't have them
|
||||
try {
|
||||
await queryInterface.addColumn('projects_tags', 'created_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
} catch (error) {
|
||||
// Column might already exist
|
||||
}
|
||||
|
||||
try {
|
||||
await queryInterface.addColumn('projects_tags', 'updated_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
} catch (error) {
|
||||
// Column might already exist
|
||||
}
|
||||
}
|
||||
},
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Create the projects_tags table if it doesn't exist
|
||||
const tableExists = await queryInterface
|
||||
.showAllTables()
|
||||
.then((tables) => tables.includes('projects_tags'));
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove timestamps or drop table if needed
|
||||
try {
|
||||
await queryInterface.removeColumn('projects_tags', 'created_at');
|
||||
await queryInterface.removeColumn('projects_tags', 'updated_at');
|
||||
} catch (error) {
|
||||
// Columns might not exist
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!tableExists) {
|
||||
await queryInterface.createTable('projects_tags', {
|
||||
project_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'projects',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
tag_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tags',
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
});
|
||||
|
||||
// Add composite primary key
|
||||
await queryInterface.addConstraint('projects_tags', {
|
||||
fields: ['project_id', 'tag_id'],
|
||||
type: 'primary key',
|
||||
name: 'projects_tags_pkey',
|
||||
});
|
||||
} else {
|
||||
// Add timestamps if table exists but doesn't have them
|
||||
try {
|
||||
await queryInterface.addColumn('projects_tags', 'created_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
});
|
||||
} catch (error) {
|
||||
// Column might already exist
|
||||
}
|
||||
|
||||
try {
|
||||
await queryInterface.addColumn('projects_tags', 'updated_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
});
|
||||
} catch (error) {
|
||||
// Column might already exist
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove timestamps or drop table if needed
|
||||
try {
|
||||
await queryInterface.removeColumn('projects_tags', 'created_at');
|
||||
await queryInterface.removeColumn('projects_tags', 'updated_at');
|
||||
} catch (error) {
|
||||
// Columns might not exist
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,40 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Area = sequelize.define('Area', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'areas',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
});
|
||||
const Area = sequelize.define(
|
||||
'Area',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'areas',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return Area;
|
||||
};
|
||||
return Area;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,77 +1,81 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../config/database');
|
||||
|
||||
const CalendarToken = sequelize.define('CalendarToken', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
provider: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'google'
|
||||
},
|
||||
access_token: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
refresh_token: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
token_type: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'Bearer'
|
||||
},
|
||||
expires_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
scope: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
connected_email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'calendar_tokens',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
const CalendarToken = sequelize.define(
|
||||
'CalendarToken',
|
||||
{
|
||||
unique: true,
|
||||
fields: ['user_id', 'provider']
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
provider: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'google',
|
||||
},
|
||||
access_token: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
refresh_token: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
token_type: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'Bearer',
|
||||
},
|
||||
expires_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
scope: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
connected_email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'calendar_tokens',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['user_id', 'provider'],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
});
|
||||
);
|
||||
|
||||
// Associations
|
||||
CalendarToken.associate = function(models) {
|
||||
CalendarToken.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'user'
|
||||
});
|
||||
CalendarToken.associate = function (models) {
|
||||
CalendarToken.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'user',
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = CalendarToken;
|
||||
module.exports = CalendarToken;
|
||||
|
|
|
|||
|
|
@ -1,42 +1,46 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const InboxItem = sequelize.define('InboxItem', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'added'
|
||||
},
|
||||
source: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'tududi'
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'inbox_items',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
});
|
||||
const InboxItem = sequelize.define(
|
||||
'InboxItem',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'added',
|
||||
},
|
||||
source: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'tududi',
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'inbox_items',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return InboxItem;
|
||||
};
|
||||
return InboxItem;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,35 +5,41 @@ const path = require('path');
|
|||
let dbConfig;
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// Use temporary file database for tests to allow external script access
|
||||
const testDbPath = path.join(__dirname, '../db', 'test.sqlite3');
|
||||
dbConfig = {
|
||||
dialect: 'sqlite',
|
||||
storage: testDbPath,
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
};
|
||||
// Use temporary file database for tests to allow external script access
|
||||
const testDbPath = path.join(__dirname, '../db', 'test.sqlite3');
|
||||
dbConfig = {
|
||||
dialect: 'sqlite',
|
||||
storage: testDbPath,
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const dbPath = process.env.DATABASE_URL
|
||||
? process.env.DATABASE_URL.replace('sqlite:///', '')
|
||||
: path.join(__dirname, '../db', process.env.NODE_ENV === 'production' ? 'production.sqlite3' : 'development.sqlite3');
|
||||
const dbPath = process.env.DATABASE_URL
|
||||
? process.env.DATABASE_URL.replace('sqlite:///', '')
|
||||
: path.join(
|
||||
__dirname,
|
||||
'../db',
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'production.sqlite3'
|
||||
: 'development.sqlite3'
|
||||
);
|
||||
|
||||
dbConfig = {
|
||||
dialect: 'sqlite',
|
||||
storage: dbPath,
|
||||
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
};
|
||||
dbConfig = {
|
||||
dialect: 'sqlite',
|
||||
storage: dbPath,
|
||||
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sequelize = new Sequelize(dbConfig);
|
||||
|
|
@ -80,23 +86,47 @@ Task.hasMany(TaskEvent, { foreignKey: 'task_id', as: 'TaskEvents' });
|
|||
TaskEvent.belongsTo(Task, { foreignKey: 'task_id', as: 'Task' });
|
||||
|
||||
// Many-to-many associations
|
||||
Task.belongsToMany(Tag, { through: 'tasks_tags', foreignKey: 'task_id', otherKey: 'tag_id' });
|
||||
Tag.belongsToMany(Task, { through: 'tasks_tags', foreignKey: 'tag_id', otherKey: 'task_id' });
|
||||
Task.belongsToMany(Tag, {
|
||||
through: 'tasks_tags',
|
||||
foreignKey: 'task_id',
|
||||
otherKey: 'tag_id',
|
||||
});
|
||||
Tag.belongsToMany(Task, {
|
||||
through: 'tasks_tags',
|
||||
foreignKey: 'tag_id',
|
||||
otherKey: 'task_id',
|
||||
});
|
||||
|
||||
Note.belongsToMany(Tag, { through: 'notes_tags', foreignKey: 'note_id', otherKey: 'tag_id' });
|
||||
Tag.belongsToMany(Note, { through: 'notes_tags', foreignKey: 'tag_id', otherKey: 'note_id' });
|
||||
Note.belongsToMany(Tag, {
|
||||
through: 'notes_tags',
|
||||
foreignKey: 'note_id',
|
||||
otherKey: 'tag_id',
|
||||
});
|
||||
Tag.belongsToMany(Note, {
|
||||
through: 'notes_tags',
|
||||
foreignKey: 'tag_id',
|
||||
otherKey: 'note_id',
|
||||
});
|
||||
|
||||
Project.belongsToMany(Tag, { through: 'projects_tags', foreignKey: 'project_id', otherKey: 'tag_id' });
|
||||
Tag.belongsToMany(Project, { through: 'projects_tags', foreignKey: 'tag_id', otherKey: 'project_id' });
|
||||
Project.belongsToMany(Tag, {
|
||||
through: 'projects_tags',
|
||||
foreignKey: 'project_id',
|
||||
otherKey: 'tag_id',
|
||||
});
|
||||
Tag.belongsToMany(Project, {
|
||||
through: 'projects_tags',
|
||||
foreignKey: 'tag_id',
|
||||
otherKey: 'project_id',
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
User,
|
||||
Area,
|
||||
Project,
|
||||
Task,
|
||||
Tag,
|
||||
Note,
|
||||
InboxItem,
|
||||
TaskEvent
|
||||
};
|
||||
sequelize,
|
||||
User,
|
||||
Area,
|
||||
Project,
|
||||
Task,
|
||||
Tag,
|
||||
Note,
|
||||
InboxItem,
|
||||
TaskEvent,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,47 +1,51 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Note = sequelize.define('Note', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'projects',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'notes',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['project_id']
|
||||
}
|
||||
]
|
||||
});
|
||||
const Note = sequelize.define(
|
||||
'Note',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
content: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'projects',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'notes',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
{
|
||||
fields: ['project_id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return Note;
|
||||
};
|
||||
return Note;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,73 +1,77 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Project = sequelize.define('Project', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
pin_to_sidebar: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 2
|
||||
}
|
||||
},
|
||||
due_date_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
area_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'areas',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
image_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'projects',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['area_id']
|
||||
}
|
||||
]
|
||||
});
|
||||
const Project = sequelize.define(
|
||||
'Project',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
pin_to_sidebar: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 2,
|
||||
},
|
||||
},
|
||||
due_date_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
area_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'areas',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
image_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'projects',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
{
|
||||
fields: ['area_id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return Project;
|
||||
};
|
||||
return Project;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,32 +1,36 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Tag = sequelize.define('Tag', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'tags',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
});
|
||||
const Tag = sequelize.define(
|
||||
'Tag',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'tags',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return Tag;
|
||||
};
|
||||
return Tag;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,219 +1,231 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Task = sequelize.define('Task', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
uuid: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: DataTypes.UUIDV4
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
due_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
today: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 2
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 4
|
||||
}
|
||||
},
|
||||
note: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
recurrence_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'none'
|
||||
},
|
||||
recurrence_interval: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
recurrence_end_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
last_generated_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
recurrence_weekday: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 6
|
||||
}
|
||||
},
|
||||
recurrence_month_day: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: -1,
|
||||
max: 31
|
||||
}
|
||||
},
|
||||
recurrence_week_of_month: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: 1,
|
||||
max: 5
|
||||
}
|
||||
},
|
||||
completion_based: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'projects',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
recurring_parent_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'tasks',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['project_id']
|
||||
},
|
||||
{
|
||||
fields: ['recurrence_type']
|
||||
},
|
||||
{
|
||||
fields: ['last_generated_date']
|
||||
}
|
||||
]
|
||||
});
|
||||
const Task = sequelize.define(
|
||||
'Task',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
uuid: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
due_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
today: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 2,
|
||||
},
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 4,
|
||||
},
|
||||
},
|
||||
note: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
recurrence_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'none',
|
||||
},
|
||||
recurrence_interval: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
recurrence_end_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
last_generated_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
recurrence_weekday: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: 0,
|
||||
max: 6,
|
||||
},
|
||||
},
|
||||
recurrence_month_day: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: -1,
|
||||
max: 31,
|
||||
},
|
||||
},
|
||||
recurrence_week_of_month: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
min: 1,
|
||||
max: 5,
|
||||
},
|
||||
},
|
||||
completion_based: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
project_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'projects',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
recurring_parent_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'tasks',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
{
|
||||
fields: ['project_id'],
|
||||
},
|
||||
{
|
||||
fields: ['recurrence_type'],
|
||||
},
|
||||
{
|
||||
fields: ['last_generated_date'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
// Define associations
|
||||
Task.associate = function(models) {
|
||||
// Self-referencing association for recurring tasks
|
||||
Task.belongsTo(models.Task, {
|
||||
as: 'RecurringParent',
|
||||
foreignKey: 'recurring_parent_id'
|
||||
});
|
||||
|
||||
Task.hasMany(models.Task, {
|
||||
as: 'RecurringChildren',
|
||||
foreignKey: 'recurring_parent_id'
|
||||
});
|
||||
};
|
||||
// Define associations
|
||||
Task.associate = function (models) {
|
||||
// Self-referencing association for recurring tasks
|
||||
Task.belongsTo(models.Task, {
|
||||
as: 'RecurringParent',
|
||||
foreignKey: 'recurring_parent_id',
|
||||
});
|
||||
|
||||
// Define enum constants
|
||||
Task.PRIORITY = {
|
||||
LOW: 0,
|
||||
MEDIUM: 1,
|
||||
HIGH: 2
|
||||
};
|
||||
|
||||
Task.STATUS = {
|
||||
NOT_STARTED: 0,
|
||||
IN_PROGRESS: 1,
|
||||
DONE: 2,
|
||||
ARCHIVED: 3,
|
||||
WAITING: 4
|
||||
};
|
||||
|
||||
Task.RECURRENCE_TYPE = {
|
||||
NONE: 'none',
|
||||
DAILY: 'daily',
|
||||
WEEKLY: 'weekly',
|
||||
MONTHLY: 'monthly',
|
||||
MONTHLY_WEEKDAY: 'monthly_weekday',
|
||||
MONTHLY_LAST_DAY: 'monthly_last_day'
|
||||
};
|
||||
|
||||
// priority and status
|
||||
const getPriorityName = (priorityValue) => {
|
||||
const priorities = ['low', 'medium', 'high'];
|
||||
return priorities[priorityValue] || 'low';
|
||||
};
|
||||
|
||||
const getStatusName = (statusValue) => {
|
||||
const statuses = ['not_started', 'in_progress', 'done', 'archived', 'waiting'];
|
||||
return statuses[statusValue] || 'not_started';
|
||||
};
|
||||
|
||||
const getPriorityValue = (priorityName) => {
|
||||
const priorities = { 'low': 0, 'medium': 1, 'high': 2 };
|
||||
return priorities[priorityName] !== undefined ? priorities[priorityName] : 0;
|
||||
};
|
||||
|
||||
const getStatusValue = (statusName) => {
|
||||
const statuses = {
|
||||
'not_started': 0,
|
||||
'in_progress': 1,
|
||||
'done': 2,
|
||||
'archived': 3,
|
||||
'waiting': 4
|
||||
Task.hasMany(models.Task, {
|
||||
as: 'RecurringChildren',
|
||||
foreignKey: 'recurring_parent_id',
|
||||
});
|
||||
};
|
||||
return statuses[statusName] !== undefined ? statuses[statusName] : 0;
|
||||
};
|
||||
|
||||
// Attach utility functions to model
|
||||
Task.getPriorityName = getPriorityName;
|
||||
Task.getStatusName = getStatusName;
|
||||
Task.getPriorityValue = getPriorityValue;
|
||||
Task.getStatusValue = getStatusValue;
|
||||
// Define enum constants
|
||||
Task.PRIORITY = {
|
||||
LOW: 0,
|
||||
MEDIUM: 1,
|
||||
HIGH: 2,
|
||||
};
|
||||
|
||||
return Task;
|
||||
};
|
||||
Task.STATUS = {
|
||||
NOT_STARTED: 0,
|
||||
IN_PROGRESS: 1,
|
||||
DONE: 2,
|
||||
ARCHIVED: 3,
|
||||
WAITING: 4,
|
||||
};
|
||||
|
||||
Task.RECURRENCE_TYPE = {
|
||||
NONE: 'none',
|
||||
DAILY: 'daily',
|
||||
WEEKLY: 'weekly',
|
||||
MONTHLY: 'monthly',
|
||||
MONTHLY_WEEKDAY: 'monthly_weekday',
|
||||
MONTHLY_LAST_DAY: 'monthly_last_day',
|
||||
};
|
||||
|
||||
// priority and status
|
||||
const getPriorityName = (priorityValue) => {
|
||||
const priorities = ['low', 'medium', 'high'];
|
||||
return priorities[priorityValue] || 'low';
|
||||
};
|
||||
|
||||
const getStatusName = (statusValue) => {
|
||||
const statuses = [
|
||||
'not_started',
|
||||
'in_progress',
|
||||
'done',
|
||||
'archived',
|
||||
'waiting',
|
||||
];
|
||||
return statuses[statusValue] || 'not_started';
|
||||
};
|
||||
|
||||
const getPriorityValue = (priorityName) => {
|
||||
const priorities = { low: 0, medium: 1, high: 2 };
|
||||
return priorities[priorityName] !== undefined
|
||||
? priorities[priorityName]
|
||||
: 0;
|
||||
};
|
||||
|
||||
const getStatusValue = (statusName) => {
|
||||
const statuses = {
|
||||
not_started: 0,
|
||||
in_progress: 1,
|
||||
done: 2,
|
||||
archived: 3,
|
||||
waiting: 4,
|
||||
};
|
||||
return statuses[statusName] !== undefined ? statuses[statusName] : 0;
|
||||
};
|
||||
|
||||
// Attach utility functions to model
|
||||
Task.getPriorityName = getPriorityName;
|
||||
Task.getStatusName = getStatusName;
|
||||
Task.getPriorityValue = getPriorityValue;
|
||||
Task.getStatusValue = getStatusValue;
|
||||
|
||||
return Task;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,211 +1,282 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const TaskEvent = sequelize.define('TaskEvent', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
task_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
isIn: [['created', 'status_changed', 'priority_changed', 'due_date_changed',
|
||||
'project_changed', 'name_changed', 'description_changed', 'note_changed',
|
||||
'completed', 'archived', 'deleted', 'restored', 'today_changed',
|
||||
'tags_changed', 'recurrence_changed', 'recurrence_type_changed',
|
||||
'completion_based_changed', 'recurrence_end_date_changed']]
|
||||
}
|
||||
},
|
||||
old_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('old_value');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('old_value', value ? JSON.stringify(value) : null);
|
||||
}
|
||||
},
|
||||
new_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('new_value');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('new_value', value ? JSON.stringify(value) : null);
|
||||
}
|
||||
},
|
||||
field_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
isIn: [['status', 'priority', 'due_date', 'project_id', 'name', 'description',
|
||||
'note', 'today', 'tags', 'recurrence_type', 'recurrence_interval',
|
||||
'recurrence_end_date', 'recurrence_weekday', 'recurrence_month_day',
|
||||
'recurrence_week_of_month', 'completion_based']]
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('metadata');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('metadata', value ? JSON.stringify(value) : null);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'task_events',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false, // We don't need updated_at for events (they're immutable)
|
||||
indexes: [
|
||||
{
|
||||
fields: ['task_id']
|
||||
},
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['event_type']
|
||||
},
|
||||
{
|
||||
fields: ['created_at']
|
||||
},
|
||||
{
|
||||
fields: ['task_id', 'event_type']
|
||||
},
|
||||
{
|
||||
fields: ['task_id', 'created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Define associations
|
||||
TaskEvent.associate = function(models) {
|
||||
// TaskEvent belongs to Task
|
||||
TaskEvent.belongsTo(models.Task, {
|
||||
foreignKey: 'task_id',
|
||||
as: 'Task'
|
||||
});
|
||||
|
||||
// TaskEvent belongs to User
|
||||
TaskEvent.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'User'
|
||||
});
|
||||
};
|
||||
|
||||
// Helper methods for common event types
|
||||
TaskEvent.createStatusChangeEvent = async function(taskId, userId, oldStatus, newStatus, metadata = {}) {
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: 'status_changed',
|
||||
field_name: 'status',
|
||||
old_value: { status: oldStatus },
|
||||
new_value: { status: newStatus },
|
||||
metadata: metadata
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.createTaskCreatedEvent = async function(taskId, userId, taskData, metadata = {}) {
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: 'created',
|
||||
field_name: null,
|
||||
old_value: null,
|
||||
new_value: taskData,
|
||||
metadata: metadata
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.createFieldChangeEvent = async function(taskId, userId, fieldName, oldValue, newValue, metadata = {}) {
|
||||
const eventType = fieldName === 'status' && newValue === 2 ? 'completed' :
|
||||
fieldName === 'status' && newValue === 3 ? 'archived' :
|
||||
`${fieldName}_changed`;
|
||||
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
field_name: fieldName,
|
||||
old_value: { [fieldName]: oldValue },
|
||||
new_value: { [fieldName]: newValue },
|
||||
metadata: metadata
|
||||
});
|
||||
};
|
||||
|
||||
// Query helpers
|
||||
TaskEvent.getTaskTimeline = async function(taskId) {
|
||||
return await TaskEvent.findAll({
|
||||
where: { task_id: taskId },
|
||||
order: [['created_at', 'ASC']],
|
||||
include: [{
|
||||
model: sequelize.models.User,
|
||||
as: 'User',
|
||||
attributes: ['id', 'name', 'email']
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.getCompletionTime = async function(taskId) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: ['status_changed', 'created', 'completed']
|
||||
},
|
||||
order: [['created_at', 'ASC']]
|
||||
});
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const startEvent = events.find(e =>
|
||||
e.event_type === 'created' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
|
||||
const TaskEvent = sequelize.define(
|
||||
'TaskEvent',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
task_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
isIn: [
|
||||
[
|
||||
'created',
|
||||
'status_changed',
|
||||
'priority_changed',
|
||||
'due_date_changed',
|
||||
'project_changed',
|
||||
'name_changed',
|
||||
'description_changed',
|
||||
'note_changed',
|
||||
'completed',
|
||||
'archived',
|
||||
'deleted',
|
||||
'restored',
|
||||
'today_changed',
|
||||
'tags_changed',
|
||||
'recurrence_changed',
|
||||
'recurrence_type_changed',
|
||||
'completion_based_changed',
|
||||
'recurrence_end_date_changed',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
old_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('old_value');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue(
|
||||
'old_value',
|
||||
value ? JSON.stringify(value) : null
|
||||
);
|
||||
},
|
||||
},
|
||||
new_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('new_value');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue(
|
||||
'new_value',
|
||||
value ? JSON.stringify(value) : null
|
||||
);
|
||||
},
|
||||
},
|
||||
field_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
isIn: [
|
||||
[
|
||||
'status',
|
||||
'priority',
|
||||
'due_date',
|
||||
'project_id',
|
||||
'name',
|
||||
'description',
|
||||
'note',
|
||||
'today',
|
||||
'tags',
|
||||
'recurrence_type',
|
||||
'recurrence_interval',
|
||||
'recurrence_end_date',
|
||||
'recurrence_weekday',
|
||||
'recurrence_month_day',
|
||||
'recurrence_week_of_month',
|
||||
'completion_based',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('metadata');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue(
|
||||
'metadata',
|
||||
value ? JSON.stringify(value) : null
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'task_events',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false, // We don't need updated_at for events (they're immutable)
|
||||
indexes: [
|
||||
{
|
||||
fields: ['task_id'],
|
||||
},
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
{
|
||||
fields: ['event_type'],
|
||||
},
|
||||
{
|
||||
fields: ['created_at'],
|
||||
},
|
||||
{
|
||||
fields: ['task_id', 'event_type'],
|
||||
},
|
||||
{
|
||||
fields: ['task_id', 'created_at'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
const completedEvent = events.find(e =>
|
||||
e.event_type === 'completed' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
|
||||
);
|
||||
// Define associations
|
||||
TaskEvent.associate = function (models) {
|
||||
// TaskEvent belongs to Task
|
||||
TaskEvent.belongsTo(models.Task, {
|
||||
foreignKey: 'task_id',
|
||||
as: 'Task',
|
||||
});
|
||||
|
||||
if (!startEvent || !completedEvent) return null;
|
||||
|
||||
const startTime = new Date(startEvent.created_at);
|
||||
const endTime = new Date(completedEvent.created_at);
|
||||
|
||||
return {
|
||||
started_at: startTime,
|
||||
completed_at: endTime,
|
||||
duration_ms: endTime - startTime,
|
||||
duration_hours: (endTime - startTime) / (1000 * 60 * 60)
|
||||
// TaskEvent belongs to User
|
||||
TaskEvent.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'User',
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
return TaskEvent;
|
||||
};
|
||||
// Helper methods for common event types
|
||||
TaskEvent.createStatusChangeEvent = async function (
|
||||
taskId,
|
||||
userId,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
metadata = {}
|
||||
) {
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: 'status_changed',
|
||||
field_name: 'status',
|
||||
old_value: { status: oldStatus },
|
||||
new_value: { status: newStatus },
|
||||
metadata: metadata,
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.createTaskCreatedEvent = async function (
|
||||
taskId,
|
||||
userId,
|
||||
taskData,
|
||||
metadata = {}
|
||||
) {
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: 'created',
|
||||
field_name: null,
|
||||
old_value: null,
|
||||
new_value: taskData,
|
||||
metadata: metadata,
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.createFieldChangeEvent = async function (
|
||||
taskId,
|
||||
userId,
|
||||
fieldName,
|
||||
oldValue,
|
||||
newValue,
|
||||
metadata = {}
|
||||
) {
|
||||
const eventType =
|
||||
fieldName === 'status' && newValue === 2
|
||||
? 'completed'
|
||||
: fieldName === 'status' && newValue === 3
|
||||
? 'archived'
|
||||
: `${fieldName}_changed`;
|
||||
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
field_name: fieldName,
|
||||
old_value: { [fieldName]: oldValue },
|
||||
new_value: { [fieldName]: newValue },
|
||||
metadata: metadata,
|
||||
});
|
||||
};
|
||||
|
||||
// Query helpers
|
||||
TaskEvent.getTaskTimeline = async function (taskId) {
|
||||
return await TaskEvent.findAll({
|
||||
where: { task_id: taskId },
|
||||
order: [['created_at', 'ASC']],
|
||||
include: [
|
||||
{
|
||||
model: sequelize.models.User,
|
||||
as: 'User',
|
||||
attributes: ['id', 'name', 'email'],
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.getCompletionTime = async function (taskId) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: ['status_changed', 'created', 'completed'],
|
||||
},
|
||||
order: [['created_at', 'ASC']],
|
||||
});
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const startEvent = events.find(
|
||||
(e) =>
|
||||
e.event_type === 'created' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
|
||||
);
|
||||
|
||||
const completedEvent = events.find(
|
||||
(e) =>
|
||||
e.event_type === 'completed' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
|
||||
);
|
||||
|
||||
if (!startEvent || !completedEvent) return null;
|
||||
|
||||
const startTime = new Date(startEvent.created_at);
|
||||
const endTime = new Date(completedEvent.created_at);
|
||||
|
||||
return {
|
||||
started_at: startTime,
|
||||
completed_at: endTime,
|
||||
duration_ms: endTime - startTime,
|
||||
duration_hours: (endTime - startTime) / (1000 * 60 * 60),
|
||||
};
|
||||
};
|
||||
|
||||
return TaskEvent;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,135 +2,153 @@ const { DataTypes } = require('sequelize');
|
|||
const bcrypt = require('bcrypt');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const User = sequelize.define('User', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true
|
||||
}
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.VIRTUAL,
|
||||
allowNull: true
|
||||
},
|
||||
password_digest: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
field: 'password_digest'
|
||||
},
|
||||
appearance: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'light',
|
||||
validate: {
|
||||
isIn: [['light', 'dark']]
|
||||
}
|
||||
},
|
||||
language: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'en'
|
||||
},
|
||||
timezone: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'UTC'
|
||||
},
|
||||
avatar_image: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
telegram_bot_token: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
telegram_chat_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
task_summary_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
task_summary_frequency: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: 'daily',
|
||||
validate: {
|
||||
isIn: [['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h']]
|
||||
}
|
||||
},
|
||||
task_summary_last_run: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
task_summary_next_run: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
task_intelligence_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
auto_suggest_next_actions_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
pomodoro_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
today_settings: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: {
|
||||
showMetrics: false,
|
||||
showProductivity: false,
|
||||
showIntelligence: false,
|
||||
showDueToday: true,
|
||||
showCompleted: true,
|
||||
showProgressBar: true,
|
||||
showDailyQuote: true
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'users',
|
||||
hooks: {
|
||||
beforeValidate: async (user) => {
|
||||
if (user.password) {
|
||||
user.password_digest = await bcrypt.hash(user.password, 10);
|
||||
const User = sequelize.define(
|
||||
'User',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true,
|
||||
},
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.VIRTUAL,
|
||||
allowNull: true,
|
||||
},
|
||||
password_digest: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
field: 'password_digest',
|
||||
},
|
||||
appearance: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'light',
|
||||
validate: {
|
||||
isIn: [['light', 'dark']],
|
||||
},
|
||||
},
|
||||
language: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'en',
|
||||
},
|
||||
timezone: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'UTC',
|
||||
},
|
||||
avatar_image: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
telegram_bot_token: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
telegram_chat_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
task_summary_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
task_summary_frequency: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: 'daily',
|
||||
validate: {
|
||||
isIn: [
|
||||
[
|
||||
'daily',
|
||||
'weekdays',
|
||||
'weekly',
|
||||
'1h',
|
||||
'2h',
|
||||
'4h',
|
||||
'8h',
|
||||
'12h',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
task_summary_last_run: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
task_summary_next_run: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
task_intelligence_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
auto_suggest_next_actions_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
pomodoro_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
today_settings: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: {
|
||||
showMetrics: false,
|
||||
showProductivity: false,
|
||||
showIntelligence: false,
|
||||
showDueToday: true,
|
||||
showCompleted: true,
|
||||
showProgressBar: true,
|
||||
showDailyQuote: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'users',
|
||||
hooks: {
|
||||
beforeValidate: async (user) => {
|
||||
if (user.password) {
|
||||
user.password_digest = await bcrypt.hash(
|
||||
user.password,
|
||||
10
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// password operations
|
||||
const hashPassword = async (password) => {
|
||||
return await bcrypt.hash(password, 10);
|
||||
};
|
||||
// password operations
|
||||
const hashPassword = async (password) => {
|
||||
return await bcrypt.hash(password, 10);
|
||||
};
|
||||
|
||||
const checkPassword = async (password, hashedPassword) => {
|
||||
return await bcrypt.compare(password, hashedPassword);
|
||||
};
|
||||
const checkPassword = async (password, hashedPassword) => {
|
||||
return await bcrypt.compare(password, hashedPassword);
|
||||
};
|
||||
|
||||
// Attach utility functions to model
|
||||
User.hashPassword = hashPassword;
|
||||
User.checkPassword = checkPassword;
|
||||
// Attach utility functions to model
|
||||
User.hashPassword = hashPassword;
|
||||
User.checkPassword = checkPassword;
|
||||
|
||||
return User;
|
||||
};
|
||||
return User;
|
||||
};
|
||||
|
|
|
|||
19144
backend/package-lock.json
generated
19144
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,59 +1,65 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"dev": "nodemon app.js",
|
||||
"test": "cross-env NODE_ENV=test jest",
|
||||
"test:watch": "cross-env NODE_ENV=test jest --watch",
|
||||
"test:coverage": "cross-env NODE_ENV=test jest --coverage",
|
||||
"test:unit": "cross-env NODE_ENV=test jest tests/unit",
|
||||
"test:integration": "cross-env NODE_ENV=test jest tests/integration",
|
||||
"test:telegram-duplicates": "node scripts/test-telegram-duplicates.js",
|
||||
"db:init": "node scripts/db-init.js",
|
||||
"db:sync": "node scripts/db-sync.js",
|
||||
"db:migrate": "node scripts/db-migrate.js",
|
||||
"db:reset": "node scripts/db-reset.js",
|
||||
"db:status": "node scripts/db-status.js",
|
||||
"user:create": "node scripts/user-create.js",
|
||||
"migration:create": "node scripts/migration-create.js",
|
||||
"migration:run": "npx sequelize-cli db:migrate",
|
||||
"migration:undo": "npx sequelize-cli db:migrate:undo",
|
||||
"migration:undo:all": "npx sequelize-cli db:migrate:undo:all",
|
||||
"migration:status": "npx sequelize-cli db:migrate:status",
|
||||
"seed:dev": "node scripts/seed-dev-data.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"cheerio": "^1.1.0",
|
||||
"compression": "^1.8.0",
|
||||
"connect-session-sequelize": "^7.1.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.1",
|
||||
"googleapis": "^144.0.0",
|
||||
"helmet": "^8.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.1",
|
||||
"node-cron": "^4.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sequelize": "^6.37.7",
|
||||
"sqlite3": "^5.1.7",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"jest": "^30.0.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"sequelize-cli": "^6.6.2",
|
||||
"supertest": "^7.1.1"
|
||||
}
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"dev": "nodemon app.js",
|
||||
"test": "cross-env NODE_ENV=test jest",
|
||||
"test:watch": "cross-env NODE_ENV=test jest --watch",
|
||||
"test:coverage": "cross-env NODE_ENV=test jest --coverage",
|
||||
"test:unit": "cross-env NODE_ENV=test jest tests/unit",
|
||||
"test:integration": "cross-env NODE_ENV=test jest tests/integration",
|
||||
"test:telegram-duplicates": "node scripts/test-telegram-duplicates.js",
|
||||
"db:init": "node scripts/db-init.js",
|
||||
"db:sync": "node scripts/db-sync.js",
|
||||
"db:migrate": "node scripts/db-migrate.js",
|
||||
"db:reset": "node scripts/db-reset.js",
|
||||
"db:status": "node scripts/db-status.js",
|
||||
"user:create": "node scripts/user-create.js",
|
||||
"migration:create": "node scripts/migration-create.js",
|
||||
"migration:run": "npx sequelize-cli db:migrate",
|
||||
"migration:undo": "npx sequelize-cli db:migrate:undo",
|
||||
"migration:undo:all": "npx sequelize-cli db:migrate:undo:all",
|
||||
"migration:status": "npx sequelize-cli db:migrate:status",
|
||||
"seed:dev": "node scripts/seed-dev-data.js",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcrypt": "~6.0.0",
|
||||
"compression": "~1.8.0",
|
||||
"connect-session-sequelize": "~7.1.7",
|
||||
"cors": "~2.8.5",
|
||||
"dotenv": "~16.5.0",
|
||||
"eslint": "^8.0.0",
|
||||
"express": "~4.18.2",
|
||||
"express-session": "~1.18.1",
|
||||
"googleapis": "~144.0.0",
|
||||
"helmet": "~8.1.0",
|
||||
"js-yaml": "~4.1.0",
|
||||
"moment-timezone": "~0.6.0",
|
||||
"morgan": "~1.10.0",
|
||||
"multer": "~2.0.1",
|
||||
"node-cron": "~4.1.0",
|
||||
"recharts": "~2.15.4",
|
||||
"sequelize": "~6.37.7",
|
||||
"sqlite3": "~5.1.7",
|
||||
"uuid": "~11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "~7.0.3",
|
||||
"eslint-plugin-jest": "^29.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"jest": "~30.0.0",
|
||||
"nodemon": "~3.0.1",
|
||||
"prettier": "~3.6.2",
|
||||
"sequelize-cli": "~6.6.2",
|
||||
"supertest": "~7.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,127 +4,135 @@ const router = express.Router();
|
|||
|
||||
// GET /api/areas
|
||||
router.get('/areas', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const areas = await Area.findAll({
|
||||
where: { user_id: req.session.userId },
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
res.json(areas);
|
||||
} catch (error) {
|
||||
console.error('Error fetching areas:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const areas = await Area.findAll({
|
||||
where: { user_id: req.session.userId },
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
|
||||
res.json(areas);
|
||||
} catch (error) {
|
||||
console.error('Error fetching areas:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/areas/:id
|
||||
router.get('/areas/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
return res.status(404).json({
|
||||
error: "Area not found or doesn't belong to the current user.",
|
||||
});
|
||||
}
|
||||
|
||||
res.json(area);
|
||||
} catch (error) {
|
||||
console.error('Error fetching area:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
return res.status(404).json({ error: "Area not found or doesn't belong to the current user." });
|
||||
}
|
||||
|
||||
res.json(area);
|
||||
} catch (error) {
|
||||
console.error('Error fetching area:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/areas
|
||||
router.post('/areas', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Area name is required.' });
|
||||
}
|
||||
|
||||
const area = await Area.create({
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
user_id: req.session.userId,
|
||||
});
|
||||
|
||||
res.status(201).json(area);
|
||||
} catch (error) {
|
||||
console.error('Error creating area:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the area.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Area name is required.' });
|
||||
}
|
||||
|
||||
const area = await Area.create({
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
user_id: req.session.userId
|
||||
});
|
||||
|
||||
res.status(201).json(area);
|
||||
} catch (error) {
|
||||
console.error('Error creating area:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the area.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/areas/:id
|
||||
router.patch('/areas/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
return res.status(404).json({ error: 'Area not found.' });
|
||||
}
|
||||
|
||||
const { name, description } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
|
||||
await area.update(updateData);
|
||||
res.json(area);
|
||||
} catch (error) {
|
||||
console.error('Error updating area:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the area.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
return res.status(404).json({ error: 'Area not found.' });
|
||||
}
|
||||
|
||||
const { name, description } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
|
||||
await area.update(updateData);
|
||||
res.json(area);
|
||||
} catch (error) {
|
||||
console.error('Error updating area:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the area.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/areas/:id
|
||||
router.delete('/areas/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
return res.status(404).json({ error: 'Area not found.' });
|
||||
}
|
||||
|
||||
await area.destroy();
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the area.',
|
||||
});
|
||||
}
|
||||
|
||||
const area = await Area.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!area) {
|
||||
return res.status(404).json({ error: 'Area not found.' });
|
||||
}
|
||||
|
||||
await area.destroy();
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
res.status(400).json({ error: 'There was a problem deleting the area.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -4,75 +4,78 @@ const router = express.Router();
|
|||
|
||||
// Get current user
|
||||
router.get('/current_user', async (req, res) => {
|
||||
try {
|
||||
if (req.session && req.session.userId) {
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (user) {
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
language: user.language,
|
||||
appearance: user.appearance,
|
||||
timezone: user.timezone
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
if (req.session && req.session.userId) {
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (user) {
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
language: user.language,
|
||||
appearance: user.appearance,
|
||||
timezone: user.timezone,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ user: null });
|
||||
} catch (error) {
|
||||
console.error('Error fetching current user:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
res.json({ user: null });
|
||||
} catch (error) {
|
||||
console.error('Error fetching current user:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Invalid login parameters.' });
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Invalid login parameters.' });
|
||||
}
|
||||
|
||||
const user = await User.findOne({ where: { email } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ errors: ['Invalid credentials'] });
|
||||
}
|
||||
|
||||
const isValidPassword = await User.checkPassword(
|
||||
password,
|
||||
user.password_digest
|
||||
);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ errors: ['Invalid credentials'] });
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
language: user.language,
|
||||
appearance: user.appearance,
|
||||
timezone: user.timezone,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const user = await User.findOne({ where: { email } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ errors: ['Invalid credentials'] });
|
||||
}
|
||||
|
||||
const isValidPassword = await User.checkPassword(password, user.password_digest);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ errors: ['Invalid credentials'] });
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
language: user.language,
|
||||
appearance: user.appearance,
|
||||
timezone: user.timezone
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Logout error:', err);
|
||||
return res.status(500).json({ error: 'Could not log out' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Logout error:', err);
|
||||
return res.status(500).json({ error: 'Could not log out' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -8,132 +8,149 @@ const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
|
|||
|
||||
// OAuth2 client setup
|
||||
const getOAuth2Client = () => {
|
||||
return new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3002/api/calendar/oauth/callback'
|
||||
);
|
||||
return new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
process.env.GOOGLE_REDIRECT_URI ||
|
||||
'http://localhost:3002/api/calendar/oauth/callback'
|
||||
);
|
||||
};
|
||||
|
||||
// GET /api/calendar/auth - Start OAuth flow (Demo mode)
|
||||
router.get('/auth', requireAuth, (req, res) => {
|
||||
try {
|
||||
// Check if Google credentials are configured
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
// Demo mode - simulate successful connection
|
||||
console.log('Demo mode: Simulating Google Calendar connection for user:', req.currentUser.id);
|
||||
|
||||
// Simulate the callback redirect with success
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:8080';
|
||||
return res.json({
|
||||
authUrl: `${frontendUrl}/calendar?demo=true&connected=true`,
|
||||
demo: true,
|
||||
message: 'Demo mode: Google Calendar integration simulated'
|
||||
});
|
||||
try {
|
||||
// Check if Google credentials are configured
|
||||
if (
|
||||
!process.env.GOOGLE_CLIENT_ID ||
|
||||
!process.env.GOOGLE_CLIENT_SECRET
|
||||
) {
|
||||
// Demo mode - simulate successful connection
|
||||
console.log(
|
||||
'Demo mode: Simulating Google Calendar connection for user:',
|
||||
req.currentUser.id
|
||||
);
|
||||
|
||||
// Simulate the callback redirect with success
|
||||
const frontendUrl =
|
||||
process.env.FRONTEND_URL || 'http://localhost:8080';
|
||||
return res.json({
|
||||
authUrl: `${frontendUrl}/calendar?demo=true&connected=true`,
|
||||
demo: true,
|
||||
message: 'Demo mode: Google Calendar integration simulated',
|
||||
});
|
||||
}
|
||||
|
||||
// Production mode with real Google OAuth
|
||||
const oauth2Client = getOAuth2Client();
|
||||
|
||||
const authUrl = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: SCOPES,
|
||||
state: JSON.stringify({ userId: req.currentUser.id }),
|
||||
});
|
||||
|
||||
res.json({ authUrl });
|
||||
} catch (error) {
|
||||
console.error('Error generating auth URL:', error);
|
||||
res.status(500).json({ error: 'Failed to generate authorization URL' });
|
||||
}
|
||||
|
||||
// Production mode with real Google OAuth
|
||||
const oauth2Client = getOAuth2Client();
|
||||
|
||||
const authUrl = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: SCOPES,
|
||||
state: JSON.stringify({ userId: req.currentUser.id })
|
||||
});
|
||||
|
||||
res.json({ authUrl });
|
||||
} catch (error) {
|
||||
console.error('Error generating auth URL:', error);
|
||||
res.status(500).json({ error: 'Failed to generate authorization URL' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/calendar/oauth/callback - Handle OAuth callback
|
||||
router.get('/oauth/callback', async (req, res) => {
|
||||
try {
|
||||
const { code, state } = req.query;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code not provided' });
|
||||
try {
|
||||
const { code, state } = req.query;
|
||||
|
||||
if (!code) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Authorization code not provided' });
|
||||
}
|
||||
|
||||
const oauth2Client = getOAuth2Client();
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
|
||||
// Parse state to get user ID
|
||||
const { userId } = JSON.parse(state);
|
||||
|
||||
// Here you would typically save the tokens to the database
|
||||
// For now, we'll just return them (in production, store securely)
|
||||
console.log('Google Calendar tokens received for user:', userId);
|
||||
console.log('Tokens:', tokens);
|
||||
|
||||
// TODO: Save tokens to database associated with user
|
||||
// await saveGoogleTokensForUser(userId, tokens);
|
||||
|
||||
// Redirect to frontend with success
|
||||
res.redirect(
|
||||
`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?connected=true`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error handling OAuth callback:', error);
|
||||
res.redirect(
|
||||
`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?error=auth_failed`
|
||||
);
|
||||
}
|
||||
|
||||
const oauth2Client = getOAuth2Client();
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
|
||||
// Parse state to get user ID
|
||||
const { userId } = JSON.parse(state);
|
||||
|
||||
// Here you would typically save the tokens to the database
|
||||
// For now, we'll just return them (in production, store securely)
|
||||
console.log('Google Calendar tokens received for user:', userId);
|
||||
console.log('Tokens:', tokens);
|
||||
|
||||
// TODO: Save tokens to database associated with user
|
||||
// await saveGoogleTokensForUser(userId, tokens);
|
||||
|
||||
// Redirect to frontend with success
|
||||
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?connected=true`);
|
||||
} catch (error) {
|
||||
console.error('Error handling OAuth callback:', error);
|
||||
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?error=auth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/calendar/status - Check connection status
|
||||
router.get('/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
// Check if we're in demo mode or have real Google integration
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
// Demo mode - check if user has been "connected" in this session
|
||||
// For demo purposes, we'll simulate connection status
|
||||
res.json({
|
||||
connected: false, // Will be set to true after demo connection
|
||||
email: null,
|
||||
demo: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Check if we're in demo mode or have real Google integration
|
||||
if (
|
||||
!process.env.GOOGLE_CLIENT_ID ||
|
||||
!process.env.GOOGLE_CLIENT_SECRET
|
||||
) {
|
||||
// Demo mode - check if user has been "connected" in this session
|
||||
// For demo purposes, we'll simulate connection status
|
||||
res.json({
|
||||
connected: false, // Will be set to true after demo connection
|
||||
email: null,
|
||||
demo: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Check if user has valid Google Calendar tokens in database
|
||||
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
|
||||
|
||||
res.json({
|
||||
connected: false, // Change to true when tokens exist and are valid
|
||||
email: null // Return connected Google account email when available
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking calendar status:', error);
|
||||
res.status(500).json({ error: 'Failed to check calendar status' });
|
||||
}
|
||||
// TODO: Check if user has valid Google Calendar tokens in database
|
||||
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
|
||||
|
||||
res.json({
|
||||
connected: false, // Change to true when tokens exist and are valid
|
||||
email: null, // Return connected Google account email when available
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking calendar status:', error);
|
||||
res.status(500).json({ error: 'Failed to check calendar status' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/calendar/events - Get events from Google Calendar
|
||||
router.get('/events', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
|
||||
// TODO: Get tokens from database
|
||||
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
|
||||
// if (!tokens) {
|
||||
// return res.status(401).json({ error: 'Google Calendar not connected' });
|
||||
// }
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
|
||||
// For now, return sample data
|
||||
const sampleEvents = [
|
||||
{
|
||||
id: 'google-1',
|
||||
title: 'Google Calendar Event',
|
||||
start: new Date().toISOString(),
|
||||
end: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
type: 'google',
|
||||
color: '#ea4335'
|
||||
}
|
||||
];
|
||||
// TODO: Get tokens from database
|
||||
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
|
||||
// if (!tokens) {
|
||||
// return res.status(401).json({ error: 'Google Calendar not connected' });
|
||||
// }
|
||||
|
||||
res.json({ events: sampleEvents });
|
||||
// For now, return sample data
|
||||
const sampleEvents = [
|
||||
{
|
||||
id: 'google-1',
|
||||
title: 'Google Calendar Event',
|
||||
start: new Date().toISOString(),
|
||||
end: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
type: 'google',
|
||||
color: '#ea4335',
|
||||
},
|
||||
];
|
||||
|
||||
// TODO: Implement actual Google Calendar API call
|
||||
/*
|
||||
res.json({ events: sampleEvents });
|
||||
|
||||
// TODO: Implement actual Google Calendar API call
|
||||
/*
|
||||
const oauth2Client = getOAuth2Client();
|
||||
oauth2Client.setCredentials(tokens);
|
||||
|
||||
|
|
@ -161,23 +178,23 @@ router.get('/events', requireAuth, async (req, res) => {
|
|||
|
||||
res.json({ events });
|
||||
*/
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendar events:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch calendar events' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendar events:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch calendar events' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/calendar/disconnect - Disconnect Google Calendar
|
||||
router.post('/disconnect', requireAuth, async (req, res) => {
|
||||
try {
|
||||
// TODO: Remove tokens from database
|
||||
// await removeGoogleTokensForUser(req.currentUser.id);
|
||||
|
||||
res.json({ success: true, message: 'Google Calendar disconnected' });
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting calendar:', error);
|
||||
res.status(500).json({ error: 'Failed to disconnect calendar' });
|
||||
}
|
||||
try {
|
||||
// TODO: Remove tokens from database
|
||||
// await removeGoogleTokensForUser(req.currentUser.id);
|
||||
|
||||
res.json({ success: true, message: 'Google Calendar disconnected' });
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting calendar:', error);
|
||||
res.status(500).json({ error: 'Failed to disconnect calendar' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -4,154 +4,162 @@ const router = express.Router();
|
|||
|
||||
// GET /api/inbox
|
||||
router.get('/inbox', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const items = await InboxItem.findAll({
|
||||
where: {
|
||||
user_id: req.session.userId,
|
||||
status: 'added',
|
||||
},
|
||||
order: [['created_at', 'DESC']],
|
||||
});
|
||||
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
console.error('Error fetching inbox items:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const items = await InboxItem.findAll({
|
||||
where: {
|
||||
user_id: req.session.userId,
|
||||
status: 'added'
|
||||
},
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
console.error('Error fetching inbox items:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/inbox
|
||||
router.post('/inbox', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { content, source } = req.body;
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.create({
|
||||
content: content.trim(),
|
||||
source: source || 'tududi',
|
||||
user_id: req.session.userId,
|
||||
});
|
||||
|
||||
res.status(201).json(item);
|
||||
} catch (error) {
|
||||
console.error('Error creating inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the inbox item.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const { content, source } = req.body;
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.create({
|
||||
content: content.trim(),
|
||||
source: source || 'tududi',
|
||||
user_id: req.session.userId
|
||||
});
|
||||
|
||||
res.status(201).json(item);
|
||||
} catch (error) {
|
||||
console.error('Error creating inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the inbox item.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/inbox/:id
|
||||
router.get('/inbox/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error fetching inbox item:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error fetching inbox item:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/inbox/:id
|
||||
router.patch('/inbox/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
const { content, status } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (content !== undefined) updateData.content = content;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
|
||||
await item.update(updateData);
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error updating inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the inbox item.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
const { content, status } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (content !== undefined) updateData.content = content;
|
||||
if (status !== undefined) updateData.status = status;
|
||||
|
||||
await item.update(updateData);
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error updating inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the inbox item.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/inbox/:id
|
||||
router.delete('/inbox/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
// Mark as deleted instead of actual deletion
|
||||
await item.update({ status: 'deleted' });
|
||||
res.json({ message: 'Inbox item successfully deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the inbox item.',
|
||||
});
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
// Mark as deleted instead of actual deletion
|
||||
await item.update({ status: 'deleted' });
|
||||
res.json({ message: 'Inbox item successfully deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting inbox item:', error);
|
||||
res.status(400).json({ error: 'There was a problem deleting the inbox item.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/inbox/:id/process
|
||||
router.patch('/inbox/:id/process', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
await item.update({ status: 'processed' });
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error processing inbox item:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem processing the inbox item.',
|
||||
});
|
||||
}
|
||||
|
||||
const item = await InboxItem.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return res.status(404).json({ error: 'Inbox item not found.' });
|
||||
}
|
||||
|
||||
await item.update({ status: 'processed' });
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error('Error processing inbox item:', error);
|
||||
res.status(400).json({ error: 'There was a problem processing the inbox item.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -5,238 +5,246 @@ const router = express.Router();
|
|||
|
||||
// Helper function to update note tags
|
||||
async function updateNoteTags(note, tagsArray, userId) {
|
||||
if (!tagsArray || tagsArray.length === 0) {
|
||||
await note.setTags([]);
|
||||
return;
|
||||
}
|
||||
if (!tagsArray || tagsArray.length === 0) {
|
||||
await note.setTags([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tagNames = tagsArray.filter((name, index, arr) => arr.indexOf(name) === index); // unique
|
||||
const tags = await Promise.all(
|
||||
tagNames.map(async (name) => {
|
||||
const [tag] = await Tag.findOrCreate({
|
||||
where: { name, user_id: userId },
|
||||
defaults: { name, user_id: userId }
|
||||
});
|
||||
return tag;
|
||||
})
|
||||
);
|
||||
await note.setTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Failed to update tags:', error.message);
|
||||
}
|
||||
try {
|
||||
const tagNames = tagsArray.filter(
|
||||
(name, index, arr) => arr.indexOf(name) === index
|
||||
); // unique
|
||||
const tags = await Promise.all(
|
||||
tagNames.map(async (name) => {
|
||||
const [tag] = await Tag.findOrCreate({
|
||||
where: { name, user_id: userId },
|
||||
defaults: { name, user_id: userId },
|
||||
});
|
||||
return tag;
|
||||
})
|
||||
);
|
||||
await note.setTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Failed to update tags:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/notes
|
||||
router.get('/notes', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const orderBy = req.query.order_by || 'title:asc';
|
||||
const [orderColumn, orderDirection] = orderBy.split(':');
|
||||
|
||||
let whereClause = { user_id: req.session.userId };
|
||||
let includeClause = [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
];
|
||||
|
||||
// Filter by tag
|
||||
if (req.query.tag) {
|
||||
includeClause[0].where = { name: req.query.tag };
|
||||
includeClause[0].required = true;
|
||||
}
|
||||
|
||||
const notes = await Note.findAll({
|
||||
where: whereClause,
|
||||
include: includeClause,
|
||||
order: [[orderColumn, orderDirection.toUpperCase()]],
|
||||
distinct: true,
|
||||
});
|
||||
|
||||
res.json(notes);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const orderBy = req.query.order_by || 'title:asc';
|
||||
const [orderColumn, orderDirection] = orderBy.split(':');
|
||||
|
||||
let whereClause = { user_id: req.session.userId };
|
||||
let includeClause = [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] }
|
||||
];
|
||||
|
||||
// Filter by tag
|
||||
if (req.query.tag) {
|
||||
includeClause[0].where = { name: req.query.tag };
|
||||
includeClause[0].required = true;
|
||||
}
|
||||
|
||||
const notes = await Note.findAll({
|
||||
where: whereClause,
|
||||
include: includeClause,
|
||||
order: [[orderColumn, orderDirection.toUpperCase()]],
|
||||
distinct: true
|
||||
});
|
||||
|
||||
res.json(notes);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/note/:id
|
||||
router.get('/note/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
|
||||
res.json(note);
|
||||
} catch (error) {
|
||||
console.error('Error fetching note:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] }
|
||||
]
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
|
||||
res.json(note);
|
||||
} catch (error) {
|
||||
console.error('Error fetching note:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/note
|
||||
router.post('/note', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { title, content, project_id, tags } = req.body;
|
||||
|
||||
const noteAttributes = {
|
||||
title,
|
||||
content,
|
||||
user_id: req.session.userId,
|
||||
};
|
||||
|
||||
// Handle project assignment
|
||||
if (project_id && project_id.toString().trim()) {
|
||||
const project = await Project.findOne({
|
||||
where: { id: project_id, user_id: req.session.userId },
|
||||
});
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Invalid project.' });
|
||||
}
|
||||
noteAttributes.project_id = project_id;
|
||||
}
|
||||
|
||||
const note = await Note.create(noteAttributes);
|
||||
|
||||
// Handle tags - can be array of strings or array of objects with name property
|
||||
let tagNames = [];
|
||||
if (Array.isArray(tags)) {
|
||||
if (tags.every((t) => typeof t === 'string')) {
|
||||
tagNames = tags;
|
||||
} else if (tags.every((t) => typeof t === 'object' && t.name)) {
|
||||
tagNames = tags.map((t) => t.name);
|
||||
}
|
||||
}
|
||||
|
||||
await updateNoteTags(note, tagNames, req.session.userId);
|
||||
|
||||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
],
|
||||
});
|
||||
|
||||
res.status(201).json(noteWithAssociations);
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the note.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const { title, content, project_id, tags } = req.body;
|
||||
|
||||
const noteAttributes = {
|
||||
title,
|
||||
content,
|
||||
user_id: req.session.userId
|
||||
};
|
||||
|
||||
// Handle project assignment
|
||||
if (project_id && project_id.toString().trim()) {
|
||||
const project = await Project.findOne({
|
||||
where: { id: project_id, user_id: req.session.userId }
|
||||
});
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Invalid project.' });
|
||||
}
|
||||
noteAttributes.project_id = project_id;
|
||||
}
|
||||
|
||||
const note = await Note.create(noteAttributes);
|
||||
|
||||
// Handle tags - can be array of strings or array of objects with name property
|
||||
let tagNames = [];
|
||||
if (Array.isArray(tags)) {
|
||||
if (tags.every(t => typeof t === 'string')) {
|
||||
tagNames = tags;
|
||||
} else if (tags.every(t => typeof t === 'object' && t.name)) {
|
||||
tagNames = tags.map(t => t.name);
|
||||
}
|
||||
}
|
||||
|
||||
await updateNoteTags(note, tagNames, req.session.userId);
|
||||
|
||||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] }
|
||||
]
|
||||
});
|
||||
|
||||
res.status(201).json(noteWithAssociations);
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the note.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/note/:id
|
||||
router.patch('/note/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
|
||||
const { title, content, project_id, tags } = req.body;
|
||||
|
||||
const updateData = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (content !== undefined) updateData.content = content;
|
||||
|
||||
// Handle project assignment
|
||||
if (project_id !== undefined) {
|
||||
if (project_id && project_id.toString().trim()) {
|
||||
const project = await Project.findOne({
|
||||
where: { id: project_id, user_id: req.session.userId }
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Invalid project.' });
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
updateData.project_id = project_id;
|
||||
} else {
|
||||
updateData.project_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
await note.update(updateData);
|
||||
const { title, content, project_id, tags } = req.body;
|
||||
|
||||
// Handle tags if provided
|
||||
if (tags !== undefined) {
|
||||
let tagNames = [];
|
||||
if (Array.isArray(tags)) {
|
||||
if (tags.every(t => typeof t === 'string')) {
|
||||
tagNames = tags;
|
||||
} else if (tags.every(t => typeof t === 'object' && t.name)) {
|
||||
tagNames = tags.map(t => t.name);
|
||||
const updateData = {};
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (content !== undefined) updateData.content = content;
|
||||
|
||||
// Handle project assignment
|
||||
if (project_id !== undefined) {
|
||||
if (project_id && project_id.toString().trim()) {
|
||||
const project = await Project.findOne({
|
||||
where: { id: project_id, user_id: req.session.userId },
|
||||
});
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Invalid project.' });
|
||||
}
|
||||
updateData.project_id = project_id;
|
||||
} else {
|
||||
updateData.project_id = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
await updateNoteTags(note, tagNames, req.session.userId);
|
||||
|
||||
await note.update(updateData);
|
||||
|
||||
// Handle tags if provided
|
||||
if (tags !== undefined) {
|
||||
let tagNames = [];
|
||||
if (Array.isArray(tags)) {
|
||||
if (tags.every((t) => typeof t === 'string')) {
|
||||
tagNames = tags;
|
||||
} else if (tags.every((t) => typeof t === 'object' && t.name)) {
|
||||
tagNames = tags.map((t) => t.name);
|
||||
}
|
||||
}
|
||||
await updateNoteTags(note, tagNames, req.session.userId);
|
||||
}
|
||||
|
||||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] },
|
||||
],
|
||||
});
|
||||
|
||||
res.json(noteWithAssociations);
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the note.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
// Reload note with associations
|
||||
const noteWithAssociations = await Note.findByPk(note.id, {
|
||||
include: [
|
||||
{ model: Tag, through: { attributes: [] } },
|
||||
{ model: Project, required: false, attributes: ['id', 'name'] }
|
||||
]
|
||||
});
|
||||
|
||||
res.json(noteWithAssociations);
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the note.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/note/:id
|
||||
router.delete('/note/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
|
||||
await note.destroy();
|
||||
res.json({ message: 'Note deleted successfully.' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the note.',
|
||||
});
|
||||
}
|
||||
|
||||
const note = await Note.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note not found.' });
|
||||
}
|
||||
|
||||
await note.destroy();
|
||||
res.json({ message: 'Note deleted successfully.' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error);
|
||||
res.status(400).json({ error: 'There was a problem deleting the note.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -8,376 +8,422 @@ const router = express.Router();
|
|||
|
||||
// Helper function to safely format dates
|
||||
const formatDate = (date) => {
|
||||
if (!date) return null;
|
||||
try {
|
||||
const dateObj = new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return null;
|
||||
return dateObj.toISOString();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
if (!date) return null;
|
||||
try {
|
||||
const dateObj = new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return null;
|
||||
return dateObj.toISOString();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadDir = path.join(__dirname, '../uploads/projects');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, 'project-' + uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
destination: function (req, file, cb) {
|
||||
const uploadDir = path.join(__dirname, '../uploads/projects');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
cb(null, 'project-' + uniqueSuffix + path.extname(file.originalname));
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB limit
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed!'));
|
||||
}
|
||||
}
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB limit
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(
|
||||
path.extname(file.originalname).toLowerCase()
|
||||
);
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed!'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to update project tags
|
||||
async function updateProjectTags(project, tagsData, userId) {
|
||||
if (!tagsData) return;
|
||||
if (!tagsData) return;
|
||||
|
||||
const tagNames = tagsData
|
||||
.map(tag => tag.name)
|
||||
.filter(name => name && name.trim())
|
||||
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
|
||||
const tagNames = tagsData
|
||||
.map((tag) => tag.name)
|
||||
.filter((name) => name && name.trim())
|
||||
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
|
||||
|
||||
if (tagNames.length === 0) {
|
||||
await project.setTags([]);
|
||||
return;
|
||||
}
|
||||
if (tagNames.length === 0) {
|
||||
await project.setTags([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find existing tags
|
||||
const existingTags = await Tag.findAll({
|
||||
where: { user_id: userId, name: tagNames }
|
||||
});
|
||||
// Find existing tags
|
||||
const existingTags = await Tag.findAll({
|
||||
where: { user_id: userId, name: tagNames },
|
||||
});
|
||||
|
||||
// Create new tags
|
||||
const existingTagNames = existingTags.map(tag => tag.name);
|
||||
const newTagNames = tagNames.filter(name => !existingTagNames.includes(name));
|
||||
|
||||
const createdTags = await Promise.all(
|
||||
newTagNames.map(name => Tag.create({ name, user_id: userId }))
|
||||
);
|
||||
// Create new tags
|
||||
const existingTagNames = existingTags.map((tag) => tag.name);
|
||||
const newTagNames = tagNames.filter(
|
||||
(name) => !existingTagNames.includes(name)
|
||||
);
|
||||
|
||||
// Set all tags to project
|
||||
const allTags = [...existingTags, ...createdTags];
|
||||
await project.setTags(allTags);
|
||||
const createdTags = await Promise.all(
|
||||
newTagNames.map((name) => Tag.create({ name, user_id: userId }))
|
||||
);
|
||||
|
||||
// Set all tags to project
|
||||
const allTags = [...existingTags, ...createdTags];
|
||||
await project.setTags(allTags);
|
||||
}
|
||||
|
||||
// POST /api/upload/project-image
|
||||
router.post('/upload/project-image', upload.single('image'), (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image file provided' });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image file provided' });
|
||||
}
|
||||
|
||||
// Return the relative URL that can be accessed from the frontend
|
||||
const imageUrl = `/api/uploads/projects/${req.file.filename}`;
|
||||
res.json({ imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
}
|
||||
// Return the relative URL that can be accessed from the frontend
|
||||
const imageUrl = `/api/uploads/projects/${req.file.filename}`;
|
||||
res.json({ imageUrl });
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/projects
|
||||
router.get('/projects', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { active, pin_to_sidebar, area_id } = req.query;
|
||||
|
||||
let whereClause = { user_id: req.session.userId };
|
||||
|
||||
// Filter by active status
|
||||
if (active === 'true') {
|
||||
whereClause.active = true;
|
||||
} else if (active === 'false') {
|
||||
whereClause.active = false;
|
||||
}
|
||||
|
||||
// Filter by pinned status
|
||||
if (pin_to_sidebar === 'true') {
|
||||
whereClause.pin_to_sidebar = true;
|
||||
} else if (pin_to_sidebar === 'false') {
|
||||
whereClause.pin_to_sidebar = false;
|
||||
}
|
||||
|
||||
// Filter by area
|
||||
if (area_id && area_id !== '') {
|
||||
whereClause.area_id = area_id;
|
||||
}
|
||||
|
||||
const projects = await Project.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
required: false,
|
||||
attributes: ['id', 'status']
|
||||
},
|
||||
{
|
||||
model: Area,
|
||||
required: false,
|
||||
attributes: ['name']
|
||||
},
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] }
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
|
||||
const { grouped } = req.query;
|
||||
|
||||
// Calculate task status counts for each project
|
||||
const taskStatusCounts = {};
|
||||
const enhancedProjects = projects.map(project => {
|
||||
const tasks = project.Tasks || [];
|
||||
const taskStatus = {
|
||||
total: tasks.length,
|
||||
done: tasks.filter(t => t.status === 2).length,
|
||||
in_progress: tasks.filter(t => t.status === 1).length,
|
||||
not_started: tasks.filter(t => t.status === 0).length
|
||||
};
|
||||
|
||||
taskStatusCounts[project.id] = taskStatus;
|
||||
|
||||
const projectJson = project.toJSON();
|
||||
return {
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(project.due_date_at),
|
||||
task_status: taskStatus,
|
||||
completion_percentage: taskStatus.total > 0 ? Math.round((taskStatus.done / taskStatus.total) * 100) : 0
|
||||
};
|
||||
});
|
||||
const { active, pin_to_sidebar, area_id } = req.query;
|
||||
|
||||
// If grouped=true, return grouped format
|
||||
if (grouped === 'true') {
|
||||
const groupedProjects = {};
|
||||
enhancedProjects.forEach(project => {
|
||||
const areaName = project.Area ? project.Area.name : 'No Area';
|
||||
if (!groupedProjects[areaName]) {
|
||||
groupedProjects[areaName] = [];
|
||||
let whereClause = { user_id: req.session.userId };
|
||||
|
||||
// Filter by active status
|
||||
if (active === 'true') {
|
||||
whereClause.active = true;
|
||||
} else if (active === 'false') {
|
||||
whereClause.active = false;
|
||||
}
|
||||
groupedProjects[areaName].push(project);
|
||||
});
|
||||
res.json(groupedProjects);
|
||||
} else {
|
||||
res.json({
|
||||
projects: enhancedProjects
|
||||
});
|
||||
|
||||
// Filter by pinned status
|
||||
if (pin_to_sidebar === 'true') {
|
||||
whereClause.pin_to_sidebar = true;
|
||||
} else if (pin_to_sidebar === 'false') {
|
||||
whereClause.pin_to_sidebar = false;
|
||||
}
|
||||
|
||||
// Filter by area
|
||||
if (area_id && area_id !== '') {
|
||||
whereClause.area_id = area_id;
|
||||
}
|
||||
|
||||
const projects = await Project.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
required: false,
|
||||
attributes: ['id', 'status'],
|
||||
},
|
||||
{
|
||||
model: Area,
|
||||
required: false,
|
||||
attributes: ['name'],
|
||||
},
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
const { grouped } = req.query;
|
||||
|
||||
// Calculate task status counts for each project
|
||||
const taskStatusCounts = {};
|
||||
const enhancedProjects = projects.map((project) => {
|
||||
const tasks = project.Tasks || [];
|
||||
const taskStatus = {
|
||||
total: tasks.length,
|
||||
done: tasks.filter((t) => t.status === 2).length,
|
||||
in_progress: tasks.filter((t) => t.status === 1).length,
|
||||
not_started: tasks.filter((t) => t.status === 0).length,
|
||||
};
|
||||
|
||||
taskStatusCounts[project.id] = taskStatus;
|
||||
|
||||
const projectJson = project.toJSON();
|
||||
return {
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(project.due_date_at),
|
||||
task_status: taskStatus,
|
||||
completion_percentage:
|
||||
taskStatus.total > 0
|
||||
? Math.round((taskStatus.done / taskStatus.total) * 100)
|
||||
: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// If grouped=true, return grouped format
|
||||
if (grouped === 'true') {
|
||||
const groupedProjects = {};
|
||||
enhancedProjects.forEach((project) => {
|
||||
const areaName = project.Area ? project.Area.name : 'No Area';
|
||||
if (!groupedProjects[areaName]) {
|
||||
groupedProjects[areaName] = [];
|
||||
}
|
||||
groupedProjects[areaName].push(project);
|
||||
});
|
||||
res.json(groupedProjects);
|
||||
} else {
|
||||
res.json({
|
||||
projects: enhancedProjects,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/project/:id
|
||||
router.get('/project/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ model: Area, required: false, attributes: ['id', 'name'] },
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const projectJson = project.toJSON();
|
||||
const result = {
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(project.due_date_at),
|
||||
};
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching project:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{ model: Area, required: false, attributes: ['id', 'name'] },
|
||||
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
|
||||
]
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const projectJson = project.toJSON();
|
||||
const result = {
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(project.due_date_at)
|
||||
};
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching project:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/project
|
||||
router.post('/project', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
area_id,
|
||||
priority,
|
||||
due_date_at,
|
||||
image_url,
|
||||
tags,
|
||||
Tags,
|
||||
} = req.body;
|
||||
|
||||
// Handle both tags and Tags (Sequelize association format)
|
||||
const tagsData = tags || Tags;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
const projectData = {
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
area_id: area_id || null,
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
priority: priority || null,
|
||||
due_date_at: due_date_at || null,
|
||||
image_url: image_url || null,
|
||||
user_id: req.session.userId,
|
||||
};
|
||||
|
||||
const project = await Project.create(projectData);
|
||||
await updateProjectTags(project, tagsData, req.session.userId);
|
||||
|
||||
// Reload project with associations
|
||||
const projectWithAssociations = await Project.findByPk(project.id, {
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const projectJson = projectWithAssociations.toJSON();
|
||||
|
||||
res.status(201).json({
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(projectWithAssociations.due_date_at),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the project.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const { name, description, area_id, priority, due_date_at, image_url, tags, Tags } = req.body;
|
||||
|
||||
// Handle both tags and Tags (Sequelize association format)
|
||||
const tagsData = tags || Tags;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
const projectData = {
|
||||
name: name.trim(),
|
||||
description: description || '',
|
||||
area_id: area_id || null,
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
priority: priority || null,
|
||||
due_date_at: due_date_at || null,
|
||||
image_url: image_url || null,
|
||||
user_id: req.session.userId
|
||||
};
|
||||
|
||||
const project = await Project.create(projectData);
|
||||
await updateProjectTags(project, tagsData, req.session.userId);
|
||||
|
||||
// Reload project with associations
|
||||
const projectWithAssociations = await Project.findByPk(project.id, {
|
||||
include: [
|
||||
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
|
||||
]
|
||||
});
|
||||
|
||||
const projectJson = projectWithAssociations.toJSON();
|
||||
|
||||
res.status(201).json({
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(projectWithAssociations.due_date_at)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the project.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/project/:id
|
||||
router.patch('/project/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found.' });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
area_id,
|
||||
active,
|
||||
pin_to_sidebar,
|
||||
priority,
|
||||
due_date_at,
|
||||
image_url,
|
||||
tags,
|
||||
Tags,
|
||||
} = req.body;
|
||||
|
||||
// Handle both tags and Tags (Sequelize association format)
|
||||
const tagsData = tags || Tags;
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (area_id !== undefined) updateData.area_id = area_id;
|
||||
if (active !== undefined) updateData.active = active;
|
||||
if (pin_to_sidebar !== undefined)
|
||||
updateData.pin_to_sidebar = pin_to_sidebar;
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
if (due_date_at !== undefined) updateData.due_date_at = due_date_at;
|
||||
if (image_url !== undefined) updateData.image_url = image_url;
|
||||
|
||||
await project.update(updateData);
|
||||
await updateProjectTags(project, tagsData, req.session.userId);
|
||||
|
||||
// Reload project with associations
|
||||
const projectWithAssociations = await Project.findByPk(project.id, {
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const projectJson = projectWithAssociations.toJSON();
|
||||
|
||||
res.json({
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(projectWithAssociations.due_date_at),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the project.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found.' });
|
||||
}
|
||||
|
||||
const { name, description, area_id, active, pin_to_sidebar, priority, due_date_at, image_url, tags, Tags } = req.body;
|
||||
|
||||
// Handle both tags and Tags (Sequelize association format)
|
||||
const tagsData = tags || Tags;
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (area_id !== undefined) updateData.area_id = area_id;
|
||||
if (active !== undefined) updateData.active = active;
|
||||
if (pin_to_sidebar !== undefined) updateData.pin_to_sidebar = pin_to_sidebar;
|
||||
if (priority !== undefined) updateData.priority = priority;
|
||||
if (due_date_at !== undefined) updateData.due_date_at = due_date_at;
|
||||
if (image_url !== undefined) updateData.image_url = image_url;
|
||||
|
||||
await project.update(updateData);
|
||||
await updateProjectTags(project, tagsData, req.session.userId);
|
||||
|
||||
// Reload project with associations
|
||||
const projectWithAssociations = await Project.findByPk(project.id, {
|
||||
include: [
|
||||
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
|
||||
]
|
||||
});
|
||||
|
||||
const projectJson = projectWithAssociations.toJSON();
|
||||
|
||||
res.json({
|
||||
...projectJson,
|
||||
tags: projectJson.Tags || [], // Normalize Tags to tags
|
||||
due_date_at: formatDate(projectWithAssociations.due_date_at)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the project.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/project/:id
|
||||
router.delete('/project/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found.' });
|
||||
}
|
||||
|
||||
await project.destroy();
|
||||
res.json({ message: 'Project successfully deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the project.',
|
||||
});
|
||||
}
|
||||
|
||||
const project = await Project.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found.' });
|
||||
}
|
||||
|
||||
await project.destroy();
|
||||
res.json({ message: 'Project successfully deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
res.status(400).json({ error: 'There was a problem deleting the project.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -4,27 +4,27 @@ const quotesService = require('../services/quotesService');
|
|||
|
||||
// GET /api/quotes/random - Get a random quote
|
||||
router.get('/quotes/random', (req, res) => {
|
||||
try {
|
||||
const quote = quotesService.getRandomQuote();
|
||||
res.json({ quote });
|
||||
} catch (error) {
|
||||
console.error('Error getting random quote:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
try {
|
||||
const quote = quotesService.getRandomQuote();
|
||||
res.json({ quote });
|
||||
} catch (error) {
|
||||
console.error('Error getting random quote:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/quotes - Get all quotes
|
||||
router.get('/quotes', (req, res) => {
|
||||
try {
|
||||
const quotes = quotesService.getAllQuotes();
|
||||
res.json({
|
||||
quotes,
|
||||
count: quotesService.getQuotesCount()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting quotes:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
try {
|
||||
const quotes = quotesService.getAllQuotes();
|
||||
res.json({
|
||||
quotes,
|
||||
count: quotesService.getQuotesCount(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting quotes:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -4,152 +4,161 @@ const router = express.Router();
|
|||
|
||||
// GET /api/tags
|
||||
router.get('/tags', async (req, res) => {
|
||||
try {
|
||||
const tags = await Tag.findAll({
|
||||
where: { user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name'],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
try {
|
||||
const tags = await Tag.findAll({
|
||||
where: { user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name'],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
res.json(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
res.json(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tag/:id
|
||||
router.get('/tag/:id', async (req, res) => {
|
||||
try {
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name']
|
||||
});
|
||||
try {
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name'],
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
if (!tag) {
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
res.json(tag);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tag:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
res.json(tag);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tag:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tag
|
||||
router.post('/tag', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
}
|
||||
|
||||
const tag = await Tag.create({
|
||||
name: name.trim(),
|
||||
user_id: req.currentUser.id,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating tag:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem creating the tag.',
|
||||
});
|
||||
}
|
||||
|
||||
const tag = await Tag.create({
|
||||
name: name.trim(),
|
||||
user_id: req.currentUser.id
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: tag.id,
|
||||
name: tag.name
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating tag:', error);
|
||||
res.status(400).json({ error: 'There was a problem creating the tag.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/tag/:id
|
||||
router.patch('/tag/:id', async (req, res) => {
|
||||
try {
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id }
|
||||
});
|
||||
try {
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id },
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
if (!tag) {
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
}
|
||||
|
||||
await tag.update({ name: name.trim() });
|
||||
|
||||
res.json({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating tag:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem updating the tag.',
|
||||
});
|
||||
}
|
||||
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
}
|
||||
|
||||
await tag.update({ name: name.trim() });
|
||||
|
||||
res.json({
|
||||
id: tag.id,
|
||||
name: tag.name
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating tag:', error);
|
||||
res.status(400).json({ error: 'There was a problem updating the tag.' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tag/:id
|
||||
router.delete('/tag/:id', async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id }
|
||||
});
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
if (!tag) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
try {
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id },
|
||||
});
|
||||
|
||||
// Use transaction to ensure all deletions happen atomically
|
||||
// Remove all associations before deleting the tag by manually deleting from junction tables
|
||||
// Only delete from tables that exist
|
||||
try {
|
||||
await sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('tasks_tags table not found, skipping');
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('notes_tags table not found, skipping');
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query('DELETE FROM projects_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('projects_tags table not found, skipping');
|
||||
}
|
||||
if (!tag) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
// Now safely delete the tag
|
||||
await tag.destroy({ transaction });
|
||||
|
||||
await transaction.commit();
|
||||
res.json({ message: 'Tag successfully deleted' });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error deleting tag:', error);
|
||||
res.status(400).json({ error: 'There was a problem deleting the tag.' });
|
||||
}
|
||||
// Use transaction to ensure all deletions happen atomically
|
||||
// Remove all associations before deleting the tag by manually deleting from junction tables
|
||||
// Only delete from tables that exist
|
||||
try {
|
||||
await sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('tasks_tags table not found, skipping');
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('notes_tags table not found, skipping');
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query(
|
||||
'DELETE FROM projects_tags WHERE tag_id = ?',
|
||||
{
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('projects_tags table not found, skipping');
|
||||
}
|
||||
|
||||
// Now safely delete the tag
|
||||
await tag.destroy({ transaction });
|
||||
|
||||
await transaction.commit();
|
||||
res.json({ message: 'Tag successfully deleted' });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error deleting tag:', error);
|
||||
res.status(400).json({
|
||||
error: 'There was a problem deleting the tag.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -5,149 +5,166 @@ const router = express.Router();
|
|||
|
||||
// GET /api/task/:id/timeline - Get task event timeline
|
||||
router.get('/task/:id/timeline', async (req, res) => {
|
||||
try {
|
||||
const timeline = await TaskEventService.getTaskTimeline(req.params.id);
|
||||
|
||||
// Filter to only show events for tasks owned by the current user
|
||||
const userTimeline = timeline.filter(event => event.user_id === req.currentUser.id);
|
||||
|
||||
res.json(userTimeline);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task timeline:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task timeline' });
|
||||
}
|
||||
try {
|
||||
const timeline = await TaskEventService.getTaskTimeline(req.params.id);
|
||||
|
||||
// Filter to only show events for tasks owned by the current user
|
||||
const userTimeline = timeline.filter(
|
||||
(event) => event.user_id === req.currentUser.id
|
||||
);
|
||||
|
||||
res.json(userTimeline);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task timeline:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task timeline' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/task/:id/completion-time - Get task completion analytics
|
||||
router.get('/task/:id/completion-time', async (req, res) => {
|
||||
try {
|
||||
const completionTime = await TaskEventService.getTaskCompletionTime(req.params.id);
|
||||
|
||||
if (!completionTime) {
|
||||
return res.status(404).json({ error: 'Task completion data not found' });
|
||||
}
|
||||
try {
|
||||
const completionTime = await TaskEventService.getTaskCompletionTime(
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json(completionTime);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task completion time:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task completion time' });
|
||||
}
|
||||
if (!completionTime) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: 'Task completion data not found' });
|
||||
}
|
||||
|
||||
res.json(completionTime);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task completion time:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task completion time' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/user/productivity-metrics - Get user productivity metrics
|
||||
router.get('/user/productivity-metrics', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const metrics = await TaskEventService.getUserProductivityMetrics(
|
||||
req.currentUser.id,
|
||||
startDate ? new Date(startDate) : null,
|
||||
endDate ? new Date(endDate) : null
|
||||
);
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
res.json(metrics);
|
||||
} catch (error) {
|
||||
console.error('Error fetching productivity metrics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch productivity metrics' });
|
||||
}
|
||||
const metrics = await TaskEventService.getUserProductivityMetrics(
|
||||
req.currentUser.id,
|
||||
startDate ? new Date(startDate) : null,
|
||||
endDate ? new Date(endDate) : null
|
||||
);
|
||||
|
||||
res.json(metrics);
|
||||
} catch (error) {
|
||||
console.error('Error fetching productivity metrics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch productivity metrics' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/user/activity-summary - Get task activity summary
|
||||
router.get('/user/activity-summary', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ error: 'startDate and endDate are required' });
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'startDate and endDate are required' });
|
||||
}
|
||||
|
||||
const activitySummary = await TaskEventService.getTaskActivitySummary(
|
||||
req.currentUser.id,
|
||||
new Date(startDate),
|
||||
new Date(endDate)
|
||||
);
|
||||
|
||||
res.json(activitySummary);
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity summary:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity summary' });
|
||||
}
|
||||
|
||||
const activitySummary = await TaskEventService.getTaskActivitySummary(
|
||||
req.currentUser.id,
|
||||
new Date(startDate),
|
||||
new Date(endDate)
|
||||
);
|
||||
|
||||
res.json(activitySummary);
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity summary:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity summary' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/completion-analytics - Get completion time analytics for multiple tasks
|
||||
router.get('/tasks/completion-analytics', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0, projectId } = req.query;
|
||||
|
||||
// Get completed tasks for the user
|
||||
const { Task, Project } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const whereClause = {
|
||||
user_id: req.currentUser.id,
|
||||
status: 2 // completed
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
whereClause.project_id = projectId;
|
||||
}
|
||||
|
||||
const completedTasks = await Task.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{ model: Project, attributes: ['name'], required: false }
|
||||
],
|
||||
order: [['completed_at', 'DESC']],
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
});
|
||||
try {
|
||||
const { limit = 50, offset = 0, projectId } = req.query;
|
||||
|
||||
// Get completion time analytics for each task
|
||||
const analytics = [];
|
||||
for (const task of completedTasks) {
|
||||
const completionTime = await TaskEventService.getTaskCompletionTime(task.id);
|
||||
if (completionTime) {
|
||||
analytics.push({
|
||||
task_id: task.id,
|
||||
task_name: task.name,
|
||||
project_name: task.Project?.name || null,
|
||||
...completionTime
|
||||
// Get completed tasks for the user
|
||||
const { Task, Project } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const whereClause = {
|
||||
user_id: req.currentUser.id,
|
||||
status: 2, // completed
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
whereClause.project_id = projectId;
|
||||
}
|
||||
|
||||
const completedTasks = await Task.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{ model: Project, attributes: ['name'], required: false },
|
||||
],
|
||||
order: [['completed_at', 'DESC']],
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
});
|
||||
}
|
||||
|
||||
// Get completion time analytics for each task
|
||||
const analytics = [];
|
||||
for (const task of completedTasks) {
|
||||
const completionTime = await TaskEventService.getTaskCompletionTime(
|
||||
task.id
|
||||
);
|
||||
if (completionTime) {
|
||||
analytics.push({
|
||||
task_id: task.id,
|
||||
task_name: task.name,
|
||||
project_name: task.Project?.name || null,
|
||||
...completionTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary statistics
|
||||
const summary = {
|
||||
total_tasks: analytics.length,
|
||||
average_completion_hours:
|
||||
analytics.length > 0
|
||||
? analytics.reduce((sum, a) => sum + a.duration_hours, 0) /
|
||||
analytics.length
|
||||
: 0,
|
||||
median_completion_hours: 0,
|
||||
fastest_completion:
|
||||
analytics.length > 0
|
||||
? Math.min(...analytics.map((a) => a.duration_hours))
|
||||
: 0,
|
||||
slowest_completion:
|
||||
analytics.length > 0
|
||||
? Math.max(...analytics.map((a) => a.duration_hours))
|
||||
: 0,
|
||||
};
|
||||
|
||||
// Calculate median
|
||||
if (analytics.length > 0) {
|
||||
const sorted = analytics
|
||||
.map((a) => a.duration_hours)
|
||||
.sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
summary.median_completion_hours =
|
||||
sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
|
||||
res.json({
|
||||
tasks: analytics,
|
||||
summary,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching completion analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch completion analytics' });
|
||||
}
|
||||
|
||||
// Calculate summary statistics
|
||||
const summary = {
|
||||
total_tasks: analytics.length,
|
||||
average_completion_hours: analytics.length > 0
|
||||
? analytics.reduce((sum, a) => sum + a.duration_hours, 0) / analytics.length
|
||||
: 0,
|
||||
median_completion_hours: 0,
|
||||
fastest_completion: analytics.length > 0
|
||||
? Math.min(...analytics.map(a => a.duration_hours))
|
||||
: 0,
|
||||
slowest_completion: analytics.length > 0
|
||||
? Math.max(...analytics.map(a => a.duration_hours))
|
||||
: 0
|
||||
};
|
||||
|
||||
// Calculate median
|
||||
if (analytics.length > 0) {
|
||||
const sorted = analytics.map(a => a.duration_hours).sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
summary.median_completion_hours = sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
|
||||
res.json({
|
||||
tasks: analytics,
|
||||
summary
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching completion analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch completion analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,105 +5,113 @@ const router = express.Router();
|
|||
|
||||
// POST /api/telegram/start-polling
|
||||
router.post('/telegram/start-polling', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user || !user.telegram_bot_token) {
|
||||
return res.status(400).json({ error: 'Telegram bot token not set.' });
|
||||
}
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user || !user.telegram_bot_token) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Telegram bot token not set.' });
|
||||
}
|
||||
|
||||
const success = await telegramPoller.addUser(user);
|
||||
const success = await telegramPoller.addUser(user);
|
||||
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Telegram polling started',
|
||||
status: telegramPoller.getStatus()
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to start Telegram polling.' });
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Telegram polling started',
|
||||
status: telegramPoller.getStatus(),
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to start Telegram polling.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting Telegram polling:', error);
|
||||
res.status(500).json({ error: 'Failed to start Telegram polling.' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting Telegram polling:', error);
|
||||
res.status(500).json({ error: 'Failed to start Telegram polling.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/telegram/stop-polling
|
||||
router.post('/telegram/stop-polling', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const success = telegramPoller.removeUser(req.session.userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Telegram polling stopped',
|
||||
status: telegramPoller.getStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error stopping Telegram polling:', error);
|
||||
res.status(500).json({ error: 'Failed to stop Telegram polling.' });
|
||||
}
|
||||
|
||||
const success = telegramPoller.removeUser(req.session.userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Telegram polling stopped',
|
||||
status: telegramPoller.getStatus()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error stopping Telegram polling:', error);
|
||||
res.status(500).json({ error: 'Failed to stop Telegram polling.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/telegram/polling-status
|
||||
router.get('/telegram/polling-status', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: telegramPoller.getStatus()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting Telegram polling status:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
status: telegramPoller.getStatus(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting Telegram polling status:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/telegram/setup
|
||||
router.post('/telegram/setup', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Telegram bot token is required.' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
// Basic token validation - check if it looks like a Telegram bot token
|
||||
if (!/^\d+:[A-Za-z0-9_-]{35}$/.test(token)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid Telegram bot token format.' });
|
||||
}
|
||||
|
||||
// Update user's telegram bot token
|
||||
await user.update({ telegram_bot_token: token });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Telegram bot token updated successfully',
|
||||
token: token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting up Telegram:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: 'Telegram bot token is required.' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
// Basic token validation - check if it looks like a Telegram bot token
|
||||
if (!/^\d+:[A-Za-z0-9_-]{35}$/.test(token)) {
|
||||
return res.status(400).json({ error: 'Invalid Telegram bot token format.' });
|
||||
}
|
||||
|
||||
// Update user's telegram bot token
|
||||
await user.update({ telegram_bot_token: token });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Telegram bot token updated successfully',
|
||||
token: token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting up Telegram:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -6,335 +6,378 @@ const router = express.Router();
|
|||
|
||||
// Fast regex-based metadata extraction (much faster than cheerio for head content)
|
||||
function extractMetadataFromHtml(html) {
|
||||
try {
|
||||
// Extract title with priority: og:title > twitter:title > title tag
|
||||
let title = null;
|
||||
|
||||
// Try og:title first
|
||||
const ogTitleMatch = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (ogTitleMatch) {
|
||||
title = ogTitleMatch[1];
|
||||
} else {
|
||||
// Try twitter:title
|
||||
const twitterTitleMatch = html.match(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (twitterTitleMatch) {
|
||||
title = twitterTitleMatch[1];
|
||||
} else {
|
||||
// Fallback to title tag
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].trim();
|
||||
try {
|
||||
// Extract title with priority: og:title > twitter:title > title tag
|
||||
let title = null;
|
||||
|
||||
// Try og:title first
|
||||
const ogTitleMatch = html.match(
|
||||
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i
|
||||
);
|
||||
if (ogTitleMatch) {
|
||||
title = ogTitleMatch[1];
|
||||
} else {
|
||||
// Try twitter:title
|
||||
const twitterTitleMatch = html.match(
|
||||
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i
|
||||
);
|
||||
if (twitterTitleMatch) {
|
||||
title = twitterTitleMatch[1];
|
||||
} else {
|
||||
// Fallback to title tag
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up title
|
||||
if (title) {
|
||||
title = title.trim();
|
||||
// Decode common HTML entities
|
||||
title = title
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
if (title.length > 100) {
|
||||
title = title.substring(0, 100) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract image with priority: og:image > twitter:image
|
||||
let image = null;
|
||||
const ogImageMatch = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (ogImageMatch) {
|
||||
image = ogImageMatch[1];
|
||||
} else {
|
||||
const twitterImageMatch = html.match(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (twitterImageMatch) {
|
||||
image = twitterImageMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description
|
||||
let description = null;
|
||||
const ogDescMatch = html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (ogDescMatch) {
|
||||
description = ogDescMatch[1];
|
||||
} else {
|
||||
const twitterDescMatch = html.match(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (twitterDescMatch) {
|
||||
description = twitterDescMatch[1];
|
||||
} else {
|
||||
const metaDescMatch = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (metaDescMatch) {
|
||||
description = metaDescMatch[1];
|
||||
|
||||
// Clean up title
|
||||
if (title) {
|
||||
title = title.trim();
|
||||
// Decode common HTML entities
|
||||
title = title
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
if (title.length > 100) {
|
||||
title = title.substring(0, 100) + '...';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract image with priority: og:image > twitter:image
|
||||
let image = null;
|
||||
const ogImageMatch = html.match(
|
||||
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i
|
||||
);
|
||||
if (ogImageMatch) {
|
||||
image = ogImageMatch[1];
|
||||
} else {
|
||||
const twitterImageMatch = html.match(
|
||||
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i
|
||||
);
|
||||
if (twitterImageMatch) {
|
||||
image = twitterImageMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description
|
||||
let description = null;
|
||||
const ogDescMatch = html.match(
|
||||
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i
|
||||
);
|
||||
if (ogDescMatch) {
|
||||
description = ogDescMatch[1];
|
||||
} else {
|
||||
const twitterDescMatch = html.match(
|
||||
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i
|
||||
);
|
||||
if (twitterDescMatch) {
|
||||
description = twitterDescMatch[1];
|
||||
} else {
|
||||
const metaDescMatch = html.match(
|
||||
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i
|
||||
);
|
||||
if (metaDescMatch) {
|
||||
description = metaDescMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (description && description.length > 150) {
|
||||
description = description.substring(0, 150) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
image,
|
||||
description,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing HTML:', error);
|
||||
return { title: null, image: null, description: null };
|
||||
}
|
||||
|
||||
if (description && description.length > 150) {
|
||||
description = description.substring(0, 150) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
image,
|
||||
description
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing HTML:', error);
|
||||
return { title: null, image: null, description: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if text is a URL
|
||||
function isUrl(text) {
|
||||
const urlRegex = /^(https?:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
|
||||
return urlRegex.test(text.trim());
|
||||
const urlRegex =
|
||||
/^(https?:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
|
||||
return urlRegex.test(text.trim());
|
||||
}
|
||||
|
||||
// Helper function to resolve relative URLs to absolute URLs
|
||||
function resolveUrl(baseUrl, relativeUrl) {
|
||||
try {
|
||||
return new URL(relativeUrl, baseUrl).href;
|
||||
} catch {
|
||||
return relativeUrl;
|
||||
}
|
||||
try {
|
||||
return new URL(relativeUrl, baseUrl).href;
|
||||
} catch {
|
||||
return relativeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to handle YouTube URLs specially
|
||||
function handleYouTubeUrl(url) {
|
||||
const youtubeRegex = /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
const match = url.match(youtubeRegex);
|
||||
|
||||
if (match) {
|
||||
const videoId = match[1];
|
||||
|
||||
// For now, return basic YouTube info - this is fast and reliable
|
||||
return {
|
||||
title: 'YouTube Video',
|
||||
image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
||||
description: 'YouTube video'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
const youtubeRegex =
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
const match = url.match(youtubeRegex);
|
||||
|
||||
if (match) {
|
||||
const videoId = match[1];
|
||||
|
||||
// For now, return basic YouTube info - this is fast and reliable
|
||||
return {
|
||||
title: 'YouTube Video',
|
||||
image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
||||
description: 'YouTube video',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to fetch URL metadata with redirect handling
|
||||
async function fetchUrlMetadata(url, maxRedirects = 5) {
|
||||
return new Promise((resolve) => {
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
|
||||
// Handle YouTube URLs specially to avoid anti-bot issues
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) {
|
||||
const youtubeMetadata = handleYouTubeUrl(url);
|
||||
if (youtubeMetadata) {
|
||||
resolve(youtubeMetadata);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Global timeout for the entire operation
|
||||
const globalTimeout = setTimeout(() => {
|
||||
resolve(null);
|
||||
}, 3000); // 3 second max for entire operation
|
||||
|
||||
function makeRequest(currentUrl, redirectCount = 0) {
|
||||
if (redirectCount > maxRedirects) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(currentUrl);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
timeout: 2000, // Reduced from 5000ms to 2000ms
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
};
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
// Handle redirects (301, 302, 303, 307, 308)
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
const redirectUrl = new URL(res.headers.location, currentUrl).href;
|
||||
makeRequest(redirectUrl, redirectCount + 1);
|
||||
// Handle YouTube URLs specially to avoid anti-bot issues
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) {
|
||||
const youtubeMetadata = handleYouTubeUrl(url);
|
||||
if (youtubeMetadata) {
|
||||
resolve(youtubeMetadata);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If not a successful response, resolve with null
|
||||
if (res.statusCode < 200 || res.statusCode >= 400) {
|
||||
clearTimeout(globalTimeout);
|
||||
// Global timeout for the entire operation
|
||||
const globalTimeout = setTimeout(() => {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
}, 3000); // 3 second max for entire operation
|
||||
|
||||
let data = '';
|
||||
let totalBytes = 0;
|
||||
const maxBytes = 20000; // Reduced from 100KB to 20KB - most meta tags are in head
|
||||
let foundMeta = false;
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
return;
|
||||
function makeRequest(currentUrl, redirectCount = 0) {
|
||||
if (redirectCount > maxRedirects) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
|
||||
// Early termination if we've found essential meta tags and closed head
|
||||
if (!foundMeta && (data.includes('og:title') || data.includes('twitter:title') || data.includes('</title>'))) {
|
||||
foundMeta = true;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(currentUrl);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
timeout: 2000, // Reduced from 5000ms to 2000ms
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
},
|
||||
};
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
// Handle redirects (301, 302, 303, 307, 308)
|
||||
if (
|
||||
[301, 302, 303, 307, 308].includes(res.statusCode) &&
|
||||
res.headers.location
|
||||
) {
|
||||
const redirectUrl = new URL(
|
||||
res.headers.location,
|
||||
currentUrl
|
||||
).href;
|
||||
makeRequest(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a successful response, resolve with null
|
||||
if (res.statusCode < 200 || res.statusCode >= 400) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
let totalBytes = 0;
|
||||
const maxBytes = 20000; // Reduced from 100KB to 20KB - most meta tags are in head
|
||||
let foundMeta = false;
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
|
||||
// Early termination if we've found essential meta tags and closed head
|
||||
if (
|
||||
!foundMeta &&
|
||||
(data.includes('og:title') ||
|
||||
data.includes('twitter:title') ||
|
||||
data.includes('</title>'))
|
||||
) {
|
||||
foundMeta = true;
|
||||
}
|
||||
|
||||
// Stop early if we have meta tags and hit end of head
|
||||
if (foundMeta && data.includes('</head>')) {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
clearTimeout(globalTimeout);
|
||||
const metadata = extractMetadataFromHtml(data);
|
||||
|
||||
// Resolve relative image URLs to absolute
|
||||
if (
|
||||
metadata.image &&
|
||||
!metadata.image.startsWith('http')
|
||||
) {
|
||||
metadata.image = resolveUrl(
|
||||
currentUrl,
|
||||
metadata.image
|
||||
);
|
||||
}
|
||||
|
||||
resolve(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch (error) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
}
|
||||
|
||||
// Stop early if we have meta tags and hit end of head
|
||||
if (foundMeta && data.includes('</head>')) {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.on('end', () => {
|
||||
clearTimeout(globalTimeout);
|
||||
const metadata = extractMetadataFromHtml(data);
|
||||
|
||||
// Resolve relative image URLs to absolute
|
||||
if (metadata.image && !metadata.image.startsWith('http')) {
|
||||
metadata.image = resolveUrl(currentUrl, metadata.image);
|
||||
}
|
||||
|
||||
resolve(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch (error) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
makeRequest(url);
|
||||
});
|
||||
makeRequest(url);
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/url/title
|
||||
router.get('/url/title', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { url } = req.query;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
const { url } = req.query;
|
||||
|
||||
const metadata = await fetchUrlMetadata(url);
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
|
||||
if (metadata && metadata.title) {
|
||||
res.json({
|
||||
url,
|
||||
title: metadata.title,
|
||||
image: metadata.image,
|
||||
description: metadata.description
|
||||
});
|
||||
} else {
|
||||
res.json({ url, title: null, image: null, description: null, error: 'Could not extract metadata' });
|
||||
const metadata = await fetchUrlMetadata(url);
|
||||
|
||||
if (metadata && metadata.title) {
|
||||
res.json({
|
||||
url,
|
||||
title: metadata.title,
|
||||
image: metadata.image,
|
||||
description: metadata.description,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
url,
|
||||
title: null,
|
||||
image: null,
|
||||
description: null,
|
||||
error: 'Could not extract metadata',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting URL title:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting URL title:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/url/extract-from-text
|
||||
router.post('/url/extract-from-text', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { text } = req.body;
|
||||
const { text } = req.body;
|
||||
|
||||
if (!text) {
|
||||
return res.status(400).json({ error: 'Text parameter is required' });
|
||||
}
|
||||
if (!text) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Text parameter is required' });
|
||||
}
|
||||
|
||||
// Enhanced URL extraction - look for URLs with or without protocol
|
||||
const urlWithProtocolRegex = /(https?:\/\/[^\s]+)/gi;
|
||||
const urlWithoutProtocolRegex = /(?:^|\s)((?:www\.)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(?::[0-9]{1,5})?(?:\/[^\s]*)?)/gi;
|
||||
|
||||
let urls = text.match(urlWithProtocolRegex);
|
||||
|
||||
// If no URLs with protocol found, look for URLs without protocol
|
||||
if (!urls) {
|
||||
const matches = text.match(urlWithoutProtocolRegex);
|
||||
if (matches) {
|
||||
// Clean up the matches (remove leading whitespace)
|
||||
urls = matches.map(match => match.trim());
|
||||
}
|
||||
}
|
||||
// Enhanced URL extraction - look for URLs with or without protocol
|
||||
const urlWithProtocolRegex = /(https?:\/\/[^\s]+)/gi;
|
||||
const urlWithoutProtocolRegex =
|
||||
/(?:^|\s)((?:www\.)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(?::[0-9]{1,5})?(?:\/[^\s]*)?)/gi;
|
||||
|
||||
if (urls && urls.length > 0) {
|
||||
const firstUrl = urls[0];
|
||||
const metadata = await fetchUrlMetadata(firstUrl);
|
||||
|
||||
if (metadata && metadata.title) {
|
||||
res.json({
|
||||
found: true,
|
||||
url: firstUrl,
|
||||
title: metadata.title,
|
||||
image: metadata.image,
|
||||
description: metadata.description,
|
||||
originalText: text
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
found: true,
|
||||
url: firstUrl,
|
||||
title: null,
|
||||
image: null,
|
||||
description: null,
|
||||
originalText: text
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.json({ found: false });
|
||||
let urls = text.match(urlWithProtocolRegex);
|
||||
|
||||
// If no URLs with protocol found, look for URLs without protocol
|
||||
if (!urls) {
|
||||
const matches = text.match(urlWithoutProtocolRegex);
|
||||
if (matches) {
|
||||
// Clean up the matches (remove leading whitespace)
|
||||
urls = matches.map((match) => match.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (urls && urls.length > 0) {
|
||||
const firstUrl = urls[0];
|
||||
const metadata = await fetchUrlMetadata(firstUrl);
|
||||
|
||||
if (metadata && metadata.title) {
|
||||
res.json({
|
||||
found: true,
|
||||
url: firstUrl,
|
||||
title: metadata.title,
|
||||
image: metadata.image,
|
||||
description: metadata.description,
|
||||
originalText: text,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
found: true,
|
||||
url: firstUrl,
|
||||
title: null,
|
||||
image: null,
|
||||
description: null,
|
||||
originalText: text,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.json({ found: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting URL from text:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting URL from text:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -3,332 +3,423 @@ const { User } = require('../models');
|
|||
const taskSummaryService = require('../services/taskSummaryService');
|
||||
const router = express.Router();
|
||||
|
||||
const VALID_FREQUENCIES = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h'];
|
||||
const VALID_FREQUENCIES = [
|
||||
'daily',
|
||||
'weekdays',
|
||||
'weekly',
|
||||
'1h',
|
||||
'2h',
|
||||
'4h',
|
||||
'8h',
|
||||
'12h',
|
||||
];
|
||||
|
||||
// GET /api/profile
|
||||
router.get('/profile', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId, {
|
||||
attributes: [
|
||||
'id',
|
||||
'email',
|
||||
'appearance',
|
||||
'language',
|
||||
'timezone',
|
||||
'avatar_image',
|
||||
'telegram_bot_token',
|
||||
'telegram_chat_id',
|
||||
'task_summary_enabled',
|
||||
'task_summary_frequency',
|
||||
'task_intelligence_enabled',
|
||||
'auto_suggest_next_actions_enabled',
|
||||
'pomodoro_enabled',
|
||||
'today_settings',
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Profile not found.' });
|
||||
}
|
||||
|
||||
// Parse today_settings if it's a string
|
||||
if (user.today_settings && typeof user.today_settings === 'string') {
|
||||
try {
|
||||
user.today_settings = JSON.parse(user.today_settings);
|
||||
} catch (error) {
|
||||
console.error('Error parsing today_settings:', error);
|
||||
user.today_settings = null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId, {
|
||||
attributes: [
|
||||
'id', 'email', 'appearance', 'language', 'timezone',
|
||||
'avatar_image', 'telegram_bot_token', 'telegram_chat_id',
|
||||
'task_summary_enabled', 'task_summary_frequency', 'task_intelligence_enabled',
|
||||
'auto_suggest_next_actions_enabled', 'pomodoro_enabled', 'today_settings'
|
||||
]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Profile not found.' });
|
||||
}
|
||||
|
||||
// Parse today_settings if it's a string
|
||||
if (user.today_settings && typeof user.today_settings === 'string') {
|
||||
try {
|
||||
user.today_settings = JSON.parse(user.today_settings);
|
||||
} catch (error) {
|
||||
console.error('Error parsing today_settings:', error);
|
||||
user.today_settings = null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/profile
|
||||
router.patch('/profile', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Profile not found.' });
|
||||
}
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Profile not found.' });
|
||||
}
|
||||
|
||||
const { appearance, language, timezone, avatar_image, telegram_bot_token, task_intelligence_enabled, task_summary_enabled, task_summary_frequency, auto_suggest_next_actions_enabled, pomodoro_enabled, currentPassword, newPassword } = req.body;
|
||||
|
||||
const allowedUpdates = {};
|
||||
if (appearance !== undefined) allowedUpdates.appearance = appearance;
|
||||
if (language !== undefined) allowedUpdates.language = language;
|
||||
if (timezone !== undefined) allowedUpdates.timezone = timezone;
|
||||
if (avatar_image !== undefined) allowedUpdates.avatar_image = avatar_image;
|
||||
if (telegram_bot_token !== undefined) allowedUpdates.telegram_bot_token = telegram_bot_token;
|
||||
if (task_intelligence_enabled !== undefined) allowedUpdates.task_intelligence_enabled = task_intelligence_enabled;
|
||||
if (task_summary_enabled !== undefined) allowedUpdates.task_summary_enabled = task_summary_enabled;
|
||||
if (task_summary_frequency !== undefined) allowedUpdates.task_summary_frequency = task_summary_frequency;
|
||||
if (auto_suggest_next_actions_enabled !== undefined) allowedUpdates.auto_suggest_next_actions_enabled = auto_suggest_next_actions_enabled;
|
||||
if (pomodoro_enabled !== undefined) allowedUpdates.pomodoro_enabled = pomodoro_enabled;
|
||||
const {
|
||||
appearance,
|
||||
language,
|
||||
timezone,
|
||||
avatar_image,
|
||||
telegram_bot_token,
|
||||
task_intelligence_enabled,
|
||||
task_summary_enabled,
|
||||
task_summary_frequency,
|
||||
auto_suggest_next_actions_enabled,
|
||||
pomodoro_enabled,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
} = req.body;
|
||||
|
||||
// Handle password change if provided
|
||||
if (currentPassword && newPassword) {
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({
|
||||
field: 'newPassword',
|
||||
error: 'Password must be at least 6 characters'
|
||||
const allowedUpdates = {};
|
||||
if (appearance !== undefined) allowedUpdates.appearance = appearance;
|
||||
if (language !== undefined) allowedUpdates.language = language;
|
||||
if (timezone !== undefined) allowedUpdates.timezone = timezone;
|
||||
if (avatar_image !== undefined)
|
||||
allowedUpdates.avatar_image = avatar_image;
|
||||
if (telegram_bot_token !== undefined)
|
||||
allowedUpdates.telegram_bot_token = telegram_bot_token;
|
||||
if (task_intelligence_enabled !== undefined)
|
||||
allowedUpdates.task_intelligence_enabled =
|
||||
task_intelligence_enabled;
|
||||
if (task_summary_enabled !== undefined)
|
||||
allowedUpdates.task_summary_enabled = task_summary_enabled;
|
||||
if (task_summary_frequency !== undefined)
|
||||
allowedUpdates.task_summary_frequency = task_summary_frequency;
|
||||
if (auto_suggest_next_actions_enabled !== undefined)
|
||||
allowedUpdates.auto_suggest_next_actions_enabled =
|
||||
auto_suggest_next_actions_enabled;
|
||||
if (pomodoro_enabled !== undefined)
|
||||
allowedUpdates.pomodoro_enabled = pomodoro_enabled;
|
||||
|
||||
// Handle password change if provided
|
||||
if (currentPassword && newPassword) {
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({
|
||||
field: 'newPassword',
|
||||
error: 'Password must be at least 6 characters',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await User.checkPassword(
|
||||
currentPassword,
|
||||
user.password_digest
|
||||
);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({
|
||||
field: 'currentPassword',
|
||||
error: 'Current password is incorrect',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash and include new password in updates
|
||||
const hashedNewPassword = await User.hashPassword(newPassword);
|
||||
allowedUpdates.password_digest = hashedNewPassword;
|
||||
}
|
||||
|
||||
await user.update(allowedUpdates);
|
||||
|
||||
// Return updated user with limited fields
|
||||
const updatedUser = await User.findByPk(user.id, {
|
||||
attributes: [
|
||||
'id',
|
||||
'email',
|
||||
'appearance',
|
||||
'language',
|
||||
'timezone',
|
||||
'avatar_image',
|
||||
'telegram_bot_token',
|
||||
'telegram_chat_id',
|
||||
'task_intelligence_enabled',
|
||||
'task_summary_enabled',
|
||||
'task_summary_frequency',
|
||||
'auto_suggest_next_actions_enabled',
|
||||
'pomodoro_enabled',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await User.checkPassword(currentPassword, user.password_digest);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({
|
||||
field: 'currentPassword',
|
||||
error: 'Current password is incorrect'
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update profile.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
// Hash and include new password in updates
|
||||
const hashedNewPassword = await User.hashPassword(newPassword);
|
||||
allowedUpdates.password_digest = hashedNewPassword;
|
||||
}
|
||||
|
||||
await user.update(allowedUpdates);
|
||||
|
||||
// Return updated user with limited fields
|
||||
const updatedUser = await User.findByPk(user.id, {
|
||||
attributes: ['id', 'email', 'appearance', 'language', 'timezone', 'avatar_image', 'telegram_bot_token', 'telegram_chat_id', 'task_intelligence_enabled', 'task_summary_enabled', 'task_summary_frequency', 'auto_suggest_next_actions_enabled', 'pomodoro_enabled']
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update profile.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/profile/change-password
|
||||
router.post('/profile/change-password', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
error: 'Current password and new password are required',
|
||||
});
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({
|
||||
field: 'newPassword',
|
||||
error: 'Password must be at least 6 characters',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await User.checkPassword(
|
||||
currentPassword,
|
||||
user.password_digest
|
||||
);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({
|
||||
field: 'currentPassword',
|
||||
error: 'Current password is incorrect',
|
||||
});
|
||||
}
|
||||
|
||||
// Hash and update new password
|
||||
const hashedNewPassword = await User.hashPassword(newPassword);
|
||||
await user.update({ password_digest: hashedNewPassword });
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({
|
||||
field: 'newPassword',
|
||||
error: 'Password must be at least 6 characters'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await User.checkPassword(currentPassword, user.password_digest);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({
|
||||
field: 'currentPassword',
|
||||
error: 'Current password is incorrect'
|
||||
});
|
||||
}
|
||||
|
||||
// Hash and update new password
|
||||
const hashedNewPassword = await User.hashPassword(newPassword);
|
||||
await user.update({ password_digest: hashedNewPassword });
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/profile/task-summary/toggle
|
||||
router.post('/profile/task-summary/toggle', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
const enabled = !user.task_summary_enabled;
|
||||
|
||||
await user.update({ task_summary_enabled: enabled });
|
||||
|
||||
// Note: Telegram integration would need to be implemented separately
|
||||
const message = enabled
|
||||
? 'Task summary notifications have been enabled.'
|
||||
: 'Task summary notifications have been disabled.';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
enabled: enabled,
|
||||
message: message,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling task summary:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update task summary settings.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
const enabled = !user.task_summary_enabled;
|
||||
|
||||
await user.update({ task_summary_enabled: enabled });
|
||||
|
||||
// Note: Telegram integration would need to be implemented separately
|
||||
const message = enabled
|
||||
? 'Task summary notifications have been enabled.'
|
||||
: 'Task summary notifications have been disabled.';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
enabled: enabled,
|
||||
message: message
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling task summary:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update task summary settings.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/profile/task-summary/frequency
|
||||
router.post('/profile/task-summary/frequency', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { frequency } = req.body;
|
||||
|
||||
if (!frequency) {
|
||||
return res.status(400).json({ error: 'Frequency is required.' });
|
||||
}
|
||||
|
||||
if (!VALID_FREQUENCIES.includes(frequency)) {
|
||||
return res.status(400).json({ error: 'Invalid frequency value.' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
await user.update({ task_summary_frequency: frequency });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
frequency: frequency,
|
||||
message: `Task summary frequency has been set to ${frequency}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating task summary frequency:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update task summary frequency.',
|
||||
details: error.errors
|
||||
? error.errors.map((e) => e.message)
|
||||
: [error.message],
|
||||
});
|
||||
}
|
||||
|
||||
const { frequency } = req.body;
|
||||
|
||||
if (!frequency) {
|
||||
return res.status(400).json({ error: 'Frequency is required.' });
|
||||
}
|
||||
|
||||
if (!VALID_FREQUENCIES.includes(frequency)) {
|
||||
return res.status(400).json({ error: 'Invalid frequency value.' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
await user.update({ task_summary_frequency: frequency });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
frequency: frequency,
|
||||
message: `Task summary frequency has been set to ${frequency}.`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating task summary frequency:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update task summary frequency.',
|
||||
details: error.errors ? error.errors.map(e => e.message) : [error.message]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/profile/task-summary/send-now
|
||||
router.post('/profile/task-summary/send-now', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
if (!user.telegram_bot_token || !user.telegram_chat_id) {
|
||||
return res.status(400).json({ error: 'Telegram bot is not properly configured.' });
|
||||
}
|
||||
if (!user.telegram_bot_token || !user.telegram_chat_id) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'Telegram bot is not properly configured.' });
|
||||
}
|
||||
|
||||
// Send the task summary
|
||||
const success = await taskSummaryService.sendSummaryToUser(user.id);
|
||||
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Task summary was sent to your Telegram.'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ error: 'Failed to send message to Telegram.' });
|
||||
// Send the task summary
|
||||
const success = await taskSummaryService.sendSummaryToUser(user.id);
|
||||
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Task summary was sent to your Telegram.',
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: 'Failed to send message to Telegram.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending task summary:', error);
|
||||
res.status(400).json({
|
||||
error: 'Error sending message to Telegram.',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending task summary:', error);
|
||||
res.status(400).json({
|
||||
error: 'Error sending message to Telegram.',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/profile/task-summary/status
|
||||
router.get('/profile/task-summary/status', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
enabled: user.task_summary_enabled,
|
||||
frequency: user.task_summary_frequency,
|
||||
last_run: user.task_summary_last_run,
|
||||
next_run: user.task_summary_next_run
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching task summary status:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
enabled: user.task_summary_enabled,
|
||||
frequency: user.task_summary_frequency,
|
||||
last_run: user.task_summary_last_run,
|
||||
next_run: user.task_summary_next_run,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching task summary status:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/profile/today-settings
|
||||
router.put('/profile/today-settings', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
const {
|
||||
showMetrics,
|
||||
showProductivity,
|
||||
showIntelligence,
|
||||
showDueToday,
|
||||
showCompleted,
|
||||
showProgressBar,
|
||||
showDailyQuote,
|
||||
} = req.body;
|
||||
|
||||
const todaySettings = {
|
||||
showMetrics:
|
||||
showMetrics !== undefined
|
||||
? showMetrics
|
||||
: user.today_settings?.showMetrics || false,
|
||||
showProductivity:
|
||||
showProductivity !== undefined
|
||||
? showProductivity
|
||||
: user.today_settings?.showProductivity || false,
|
||||
showIntelligence:
|
||||
showIntelligence !== undefined
|
||||
? showIntelligence
|
||||
: user.today_settings?.showIntelligence || false,
|
||||
showDueToday:
|
||||
showDueToday !== undefined
|
||||
? showDueToday
|
||||
: user.today_settings?.showDueToday || true,
|
||||
showCompleted:
|
||||
showCompleted !== undefined
|
||||
? showCompleted
|
||||
: user.today_settings?.showCompleted || true,
|
||||
showProgressBar: true, // Always enabled - ignore any attempts to disable it
|
||||
showDailyQuote:
|
||||
showDailyQuote !== undefined
|
||||
? showDailyQuote
|
||||
: user.today_settings?.showDailyQuote || true,
|
||||
};
|
||||
|
||||
await user.update({ today_settings: todaySettings });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
today_settings: todaySettings,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating today settings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
const {
|
||||
showMetrics,
|
||||
showProductivity,
|
||||
showIntelligence,
|
||||
showDueToday,
|
||||
showCompleted,
|
||||
showProgressBar,
|
||||
showDailyQuote
|
||||
} = req.body;
|
||||
|
||||
const todaySettings = {
|
||||
showMetrics: showMetrics !== undefined ? showMetrics : user.today_settings?.showMetrics || false,
|
||||
showProductivity: showProductivity !== undefined ? showProductivity : user.today_settings?.showProductivity || false,
|
||||
showIntelligence: showIntelligence !== undefined ? showIntelligence : user.today_settings?.showIntelligence || false,
|
||||
showDueToday: showDueToday !== undefined ? showDueToday : user.today_settings?.showDueToday || true,
|
||||
showCompleted: showCompleted !== undefined ? showCompleted : user.today_settings?.showCompleted || true,
|
||||
showProgressBar: true, // Always enabled - ignore any attempts to disable it
|
||||
showDailyQuote: showDailyQuote !== undefined ? showDailyQuote : user.today_settings?.showDailyQuote || true
|
||||
};
|
||||
|
||||
await user.update({ today_settings: todaySettings });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
today_settings: todaySettings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating today settings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -9,19 +9,21 @@ require('dotenv').config();
|
|||
const { sequelize } = require('../models');
|
||||
|
||||
async function initDatabase() {
|
||||
try {
|
||||
console.log('Initializing database...');
|
||||
console.log('WARNING: This will drop all existing data!');
|
||||
|
||||
await sequelize.sync({ force: true });
|
||||
|
||||
console.log('✅ Database initialized successfully');
|
||||
console.log('All tables have been created and existing data has been cleared');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error initializing database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
console.log('Initializing database...');
|
||||
console.log('WARNING: This will drop all existing data!');
|
||||
|
||||
await sequelize.sync({ force: true });
|
||||
|
||||
console.log('✅ Database initialized successfully');
|
||||
console.log(
|
||||
'All tables have been created and existing data has been cleared'
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error initializing database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
initDatabase();
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ require('dotenv').config();
|
|||
const { sequelize } = require('../models');
|
||||
|
||||
async function migrateDatabase() {
|
||||
try {
|
||||
console.log('Migrating database...');
|
||||
console.log('This will alter existing tables to match current models');
|
||||
|
||||
await sequelize.sync({ alter: true });
|
||||
|
||||
console.log('✅ Database migrated successfully');
|
||||
console.log('All tables have been updated to match current models');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error migrating database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
console.log('Migrating database...');
|
||||
console.log('This will alter existing tables to match current models');
|
||||
|
||||
await sequelize.sync({ alter: true });
|
||||
|
||||
console.log('✅ Database migrated successfully');
|
||||
console.log('All tables have been updated to match current models');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error migrating database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrateDatabase();
|
||||
migrateDatabase();
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ require('dotenv').config();
|
|||
const { sequelize } = require('../models');
|
||||
|
||||
async function resetDatabase() {
|
||||
try {
|
||||
console.log('Resetting database...');
|
||||
console.log('WARNING: This will permanently delete all data!');
|
||||
|
||||
await sequelize.sync({ force: true });
|
||||
|
||||
console.log('✅ Database reset successfully');
|
||||
console.log('All tables have been dropped and recreated');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error resetting database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
console.log('Resetting database...');
|
||||
console.log('WARNING: This will permanently delete all data!');
|
||||
|
||||
await sequelize.sync({ force: true });
|
||||
|
||||
console.log('✅ Database reset successfully');
|
||||
console.log('All tables have been dropped and recreated');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error resetting database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
resetDatabase();
|
||||
resetDatabase();
|
||||
|
|
|
|||
|
|
@ -6,64 +6,75 @@
|
|||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { sequelize, User, Task, Project, Area, Note, Tag, InboxItem } = require('../models');
|
||||
const {
|
||||
sequelize,
|
||||
User,
|
||||
Task,
|
||||
Project,
|
||||
Area,
|
||||
Note,
|
||||
Tag,
|
||||
InboxItem,
|
||||
} = require('../models');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function checkDatabaseStatus() {
|
||||
try {
|
||||
console.log('🔍 Checking database status...\n');
|
||||
|
||||
// Check database file
|
||||
const dbConfig = sequelize.config || sequelize.options;
|
||||
const dbPath = dbConfig.storage || sequelize.options.storage;
|
||||
|
||||
console.log('📂 Database Configuration:');
|
||||
console.log(` Storage: ${dbPath}`);
|
||||
console.log(` Dialect: ${dbConfig.dialect || sequelize.options.dialect || 'sqlite'}`);
|
||||
console.log(` Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
|
||||
// Check if database file exists
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const stats = fs.statSync(dbPath);
|
||||
console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
|
||||
console.log(` Last modified: ${stats.mtime.toISOString()}`);
|
||||
} else {
|
||||
console.log(' ⚠️ Database file does not exist');
|
||||
try {
|
||||
console.log('🔍 Checking database status...\n');
|
||||
|
||||
// Check database file
|
||||
const dbConfig = sequelize.config || sequelize.options;
|
||||
const dbPath = dbConfig.storage || sequelize.options.storage;
|
||||
|
||||
console.log('📂 Database Configuration:');
|
||||
console.log(` Storage: ${dbPath}`);
|
||||
console.log(
|
||||
` Dialect: ${dbConfig.dialect || sequelize.options.dialect || 'sqlite'}`
|
||||
);
|
||||
console.log(` Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
|
||||
// Check if database file exists
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const stats = fs.statSync(dbPath);
|
||||
console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
|
||||
console.log(` Last modified: ${stats.mtime.toISOString()}`);
|
||||
} else {
|
||||
console.log(' ⚠️ Database file does not exist');
|
||||
}
|
||||
|
||||
console.log('\n🔌 Testing database connection...');
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connection successful\n');
|
||||
|
||||
// Get table information
|
||||
console.log('📊 Table Statistics:');
|
||||
const models = [
|
||||
{ name: 'Users', model: User },
|
||||
{ name: 'Areas', model: Area },
|
||||
{ name: 'Projects', model: Project },
|
||||
{ name: 'Tasks', model: Task },
|
||||
{ name: 'Notes', model: Note },
|
||||
{ name: 'Tags', model: Tag },
|
||||
{ name: 'Inbox Items', model: InboxItem },
|
||||
];
|
||||
|
||||
for (const { name, model } of models) {
|
||||
try {
|
||||
const count = await model.count();
|
||||
console.log(` ${name}: ${count} records`);
|
||||
} catch (error) {
|
||||
console.log(` ${name}: ❌ Error (${error.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ Database status check completed');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Database connection failed:', error.message);
|
||||
console.error('\n💡 Try running: npm run db:init');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n🔌 Testing database connection...');
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connection successful\n');
|
||||
|
||||
// Get table information
|
||||
console.log('📊 Table Statistics:');
|
||||
const models = [
|
||||
{ name: 'Users', model: User },
|
||||
{ name: 'Areas', model: Area },
|
||||
{ name: 'Projects', model: Project },
|
||||
{ name: 'Tasks', model: Task },
|
||||
{ name: 'Notes', model: Note },
|
||||
{ name: 'Tags', model: Tag },
|
||||
{ name: 'Inbox Items', model: InboxItem }
|
||||
];
|
||||
|
||||
for (const { name, model } of models) {
|
||||
try {
|
||||
const count = await model.count();
|
||||
console.log(` ${name}: ${count} records`);
|
||||
} catch (error) {
|
||||
console.log(` ${name}: ❌ Error (${error.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ Database status check completed');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Database connection failed:', error.message);
|
||||
console.error('\n💡 Try running: npm run db:init');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkDatabaseStatus();
|
||||
checkDatabaseStatus();
|
||||
|
|
|
|||
|
|
@ -9,18 +9,18 @@ require('dotenv').config();
|
|||
const { sequelize } = require('../models');
|
||||
|
||||
async function syncDatabase() {
|
||||
try {
|
||||
console.log('Syncing database...');
|
||||
|
||||
await sequelize.sync();
|
||||
|
||||
console.log('✅ Database synchronized successfully');
|
||||
console.log('All tables have been created (existing data preserved)');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error syncing database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
console.log('Syncing database...');
|
||||
|
||||
await sequelize.sync();
|
||||
|
||||
console.log('✅ Database synchronized successfully');
|
||||
console.log('All tables have been created (existing data preserved)');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error syncing database:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
syncDatabase();
|
||||
syncDatabase();
|
||||
|
|
|
|||
|
|
@ -10,28 +10,31 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
|
||||
function createMigration() {
|
||||
const migrationName = process.argv[2];
|
||||
|
||||
if (!migrationName) {
|
||||
console.error('❌ Usage: npm run migration:create <migration-name>');
|
||||
console.error('Example: npm run migration:create add-description-to-tasks');
|
||||
process.exit(1);
|
||||
}
|
||||
const migrationName = process.argv[2];
|
||||
|
||||
// Generate timestamp (YYYYMMDDHHMMSS format)
|
||||
const now = new Date();
|
||||
const timestamp = now.getFullYear().toString() +
|
||||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||
now.getDate().toString().padStart(2, '0') +
|
||||
now.getHours().toString().padStart(2, '0') +
|
||||
now.getMinutes().toString().padStart(2, '0') +
|
||||
now.getSeconds().toString().padStart(2, '0');
|
||||
if (!migrationName) {
|
||||
console.error('❌ Usage: npm run migration:create <migration-name>');
|
||||
console.error(
|
||||
'Example: npm run migration:create add-description-to-tasks'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fileName = `${timestamp}-${migrationName}.js`;
|
||||
const filePath = path.join(__dirname, '..', 'migrations', fileName);
|
||||
// Generate timestamp (YYYYMMDDHHMMSS format)
|
||||
const now = new Date();
|
||||
const timestamp =
|
||||
now.getFullYear().toString() +
|
||||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||
now.getDate().toString().padStart(2, '0') +
|
||||
now.getHours().toString().padStart(2, '0') +
|
||||
now.getMinutes().toString().padStart(2, '0') +
|
||||
now.getSeconds().toString().padStart(2, '0');
|
||||
|
||||
// Migration template
|
||||
const template = `'use strict';
|
||||
const fileName = `${timestamp}-${migrationName}.js`;
|
||||
const filePath = path.join(__dirname, '..', 'migrations', fileName);
|
||||
|
||||
// Migration template
|
||||
const template = `'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
|
|
@ -87,22 +90,22 @@ module.exports = {
|
|||
}
|
||||
};`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, template);
|
||||
console.log('✅ Migration created successfully');
|
||||
console.log(`📁 File: ${fileName}`);
|
||||
console.log(`📂 Path: ${filePath}`);
|
||||
console.log('');
|
||||
console.log('📝 Next steps:');
|
||||
console.log('1. Edit the migration file to add your schema changes');
|
||||
console.log('2. Run: npm run migration:run');
|
||||
console.log('3. To rollback: npm run migration:undo');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating migration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(filePath, template);
|
||||
console.log('✅ Migration created successfully');
|
||||
console.log(`📁 File: ${fileName}`);
|
||||
console.log(`📂 Path: ${filePath}`);
|
||||
console.log('');
|
||||
console.log('📝 Next steps:');
|
||||
console.log('1. Edit the migration file to add your schema changes');
|
||||
console.log('2. Run: npm run migration:run');
|
||||
console.log('3. To rollback: npm run migration:undo');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating migration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createMigration();
|
||||
createMigration();
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
|||
|
||||
// Ensure we're using the correct database path
|
||||
if (!process.env.DATABASE_URL) {
|
||||
process.env.DATABASE_URL = `sqlite:///${path.join(__dirname, '../db/development.sqlite3')}`;
|
||||
process.env.DATABASE_URL = `sqlite:///${path.join(__dirname, '../db/development.sqlite3')}`;
|
||||
}
|
||||
|
||||
console.log('🌱 Starting development data seeding...');
|
||||
console.log(`📁 Database: ${process.env.DATABASE_URL}`);
|
||||
console.log(`🌍 Environment: ${process.env.NODE_ENV}`);
|
||||
|
||||
seedDatabase();
|
||||
seedDatabase();
|
||||
|
|
|
|||
|
|
@ -11,67 +11,71 @@ const { User } = require('../models');
|
|||
const bcrypt = require('bcrypt');
|
||||
|
||||
async function createUser() {
|
||||
const [email, password] = process.argv.slice(2);
|
||||
|
||||
if (!email || password === undefined) {
|
||||
console.error('❌ Usage: npm run user:create <email> <password>');
|
||||
console.error('Example: npm run user:create admin@example.com mypassword123');
|
||||
process.exit(1);
|
||||
}
|
||||
const [email, password] = process.argv.slice(2);
|
||||
|
||||
// Basic password validation (check for empty or short passwords)
|
||||
if (!password || password.length < 6) {
|
||||
console.error('❌ Password must be at least 6 characters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Enhanced email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
// Check for common invalid patterns
|
||||
if (!email.includes('@') ||
|
||||
!email.includes('.') ||
|
||||
email.includes('@@') ||
|
||||
email.includes(' ') ||
|
||||
email.startsWith('@') ||
|
||||
email.endsWith('@') ||
|
||||
email.endsWith('.') ||
|
||||
email.includes('@.') ||
|
||||
email.includes('.@') ||
|
||||
!emailRegex.test(email)) {
|
||||
console.error('❌ Invalid email format');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Creating user with email: ${email}`);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.findOne({ where: { email } });
|
||||
if (existingUser) {
|
||||
console.error(`❌ User with email ${email} already exists`);
|
||||
process.exit(1);
|
||||
if (!email || password === undefined) {
|
||||
console.error('❌ Usage: npm run user:create <email> <password>');
|
||||
console.error(
|
||||
'Example: npm run user:create admin@example.com mypassword123'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Basic password validation (check for empty or short passwords)
|
||||
if (!password || password.length < 6) {
|
||||
console.error('❌ Password must be at least 6 characters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Enhanced email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
// Check for common invalid patterns
|
||||
if (
|
||||
!email.includes('@') ||
|
||||
!email.includes('.') ||
|
||||
email.includes('@@') ||
|
||||
email.includes(' ') ||
|
||||
email.startsWith('@') ||
|
||||
email.endsWith('@') ||
|
||||
email.endsWith('.') ||
|
||||
email.includes('@.') ||
|
||||
email.includes('.@') ||
|
||||
!emailRegex.test(email)
|
||||
) {
|
||||
console.error('❌ Invalid email format');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Creating user with email: ${email}`);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.findOne({ where: { email } });
|
||||
if (existingUser) {
|
||||
console.error(`❌ User with email ${email} already exists`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Hash the password manually since the hook might not be working in this context
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create the user
|
||||
const user = await User.create({
|
||||
email,
|
||||
password_digest: hashedPassword,
|
||||
});
|
||||
|
||||
console.log('✅ User created successfully');
|
||||
console.log(`📧 Email: ${user.email}`);
|
||||
console.log(`🆔 User ID: ${user.id}`);
|
||||
console.log(`📅 Created: ${user.created_at}`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating user:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Hash the password manually since the hook might not be working in this context
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create the user
|
||||
const user = await User.create({
|
||||
email,
|
||||
password_digest: hashedPassword
|
||||
});
|
||||
|
||||
console.log('✅ User created successfully');
|
||||
console.log(`📧 Email: ${user.email}`);
|
||||
console.log(`🆔 User ID: ${user.id}`);
|
||||
console.log(`📅 Created: ${user.created_at}`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating user:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createUser();
|
||||
createUser();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -4,128 +4,119 @@ const yaml = require('js-yaml');
|
|||
|
||||
// create default quotes
|
||||
const createDefaultQuotes = () => [
|
||||
"Believe you can and you're halfway there.",
|
||||
"The only way to do great work is to love what you do.",
|
||||
"It always seems impossible until it's done.",
|
||||
"Focus on progress, not perfection.",
|
||||
"One task at a time leads to great accomplishments."
|
||||
"Believe you can and you're halfway there.",
|
||||
'The only way to do great work is to love what you do.',
|
||||
"It always seems impossible until it's done.",
|
||||
'Focus on progress, not perfection.',
|
||||
'One task at a time leads to great accomplishments.',
|
||||
];
|
||||
|
||||
// get quotes file path
|
||||
const getQuotesFilePath = () =>
|
||||
path.join(__dirname, '../config/quotes.yml');
|
||||
const getQuotesFilePath = () => path.join(__dirname, '../config/quotes.yml');
|
||||
|
||||
// Side effect function to check if file exists
|
||||
const fileExists = (filePath) =>
|
||||
fs.existsSync(filePath);
|
||||
const fileExists = (filePath) => fs.existsSync(filePath);
|
||||
|
||||
// Side effect function to read file contents
|
||||
const readFileContents = (filePath) =>
|
||||
fs.readFileSync(filePath, 'utf8');
|
||||
const readFileContents = (filePath) => fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// parse YAML content
|
||||
const parseYamlContent = (content) => {
|
||||
try {
|
||||
return yaml.load(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse YAML: ${error.message}`);
|
||||
}
|
||||
try {
|
||||
return yaml.load(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse YAML: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// validate quotes data structure
|
||||
const validateQuotesData = (data) =>
|
||||
!!(data && data.quotes && Array.isArray(data.quotes));
|
||||
const validateQuotesData = (data) =>
|
||||
!!(data && data.quotes && Array.isArray(data.quotes));
|
||||
|
||||
// extract quotes from data
|
||||
const extractQuotes = (data) => {
|
||||
if (validateQuotesData(data)) {
|
||||
return data.quotes;
|
||||
}
|
||||
return null;
|
||||
if (validateQuotesData(data)) {
|
||||
return data.quotes;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Side effect function to load quotes from file
|
||||
const loadQuotesFromFile = () => {
|
||||
try {
|
||||
const quotesPath = getQuotesFilePath();
|
||||
|
||||
if (!fileExists(quotesPath)) {
|
||||
console.warn('Quotes configuration file not found, using defaults');
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
try {
|
||||
const quotesPath = getQuotesFilePath();
|
||||
|
||||
const fileContents = readFileContents(quotesPath);
|
||||
const data = parseYamlContent(fileContents);
|
||||
const quotes = extractQuotes(data);
|
||||
|
||||
if (quotes) {
|
||||
console.log(`Loaded ${quotes.length} quotes from configuration`);
|
||||
return quotes;
|
||||
} else {
|
||||
console.warn('No quotes found in configuration file');
|
||||
return createDefaultQuotes();
|
||||
if (!fileExists(quotesPath)) {
|
||||
console.warn('Quotes configuration file not found, using defaults');
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
|
||||
const fileContents = readFileContents(quotesPath);
|
||||
const data = parseYamlContent(fileContents);
|
||||
const quotes = extractQuotes(data);
|
||||
|
||||
if (quotes) {
|
||||
console.log(`Loaded ${quotes.length} quotes from configuration`);
|
||||
return quotes;
|
||||
} else {
|
||||
console.warn('No quotes found in configuration file');
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quotes:', error.message);
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quotes:', error.message);
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
};
|
||||
|
||||
// get random index
|
||||
const getRandomIndex = (arrayLength) =>
|
||||
Math.floor(Math.random() * arrayLength);
|
||||
const getRandomIndex = (arrayLength) => Math.floor(Math.random() * arrayLength);
|
||||
|
||||
// get random quote from array
|
||||
const getRandomQuoteFromArray = (quotes) => {
|
||||
if (quotes.length === 0) {
|
||||
return "Stay focused and keep going!";
|
||||
}
|
||||
|
||||
const randomIndex = getRandomIndex(quotes.length);
|
||||
return quotes[randomIndex];
|
||||
if (quotes.length === 0) {
|
||||
return 'Stay focused and keep going!';
|
||||
}
|
||||
|
||||
const randomIndex = getRandomIndex(quotes.length);
|
||||
return quotes[randomIndex];
|
||||
};
|
||||
|
||||
// get all quotes
|
||||
const getAllQuotesFromArray = (quotes) =>
|
||||
[...quotes]; // Return copy to maintain immutability
|
||||
const getAllQuotesFromArray = (quotes) => [...quotes]; // Return copy to maintain immutability
|
||||
|
||||
// get quotes count
|
||||
const getQuotesCount = (quotes) =>
|
||||
quotes.length;
|
||||
const getQuotesCount = (quotes) => quotes.length;
|
||||
|
||||
// Initialize quotes on module load
|
||||
let quotes = loadQuotesFromFile();
|
||||
|
||||
// Function to reload quotes (contains side effects)
|
||||
const reloadQuotes = () => {
|
||||
quotes = loadQuotesFromFile();
|
||||
return quotes;
|
||||
quotes = loadQuotesFromFile();
|
||||
return quotes;
|
||||
};
|
||||
|
||||
// get random quote
|
||||
const getRandomQuote = () =>
|
||||
getRandomQuoteFromArray(quotes);
|
||||
const getRandomQuote = () => getRandomQuoteFromArray(quotes);
|
||||
|
||||
// get all quotes
|
||||
const getAllQuotes = () =>
|
||||
getAllQuotesFromArray(quotes);
|
||||
const getAllQuotes = () => getAllQuotesFromArray(quotes);
|
||||
|
||||
// get count
|
||||
const getCount = () =>
|
||||
getQuotesCount(quotes);
|
||||
const getCount = () => getQuotesCount(quotes);
|
||||
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
getRandomQuote,
|
||||
getAllQuotes,
|
||||
getQuotesCount: getCount,
|
||||
reloadQuotes,
|
||||
// For testing
|
||||
_createDefaultQuotes: createDefaultQuotes,
|
||||
_getQuotesFilePath: getQuotesFilePath,
|
||||
_parseYamlContent: parseYamlContent,
|
||||
_validateQuotesData: validateQuotesData,
|
||||
_extractQuotes: extractQuotes,
|
||||
_getRandomIndex: getRandomIndex,
|
||||
_getRandomQuoteFromArray: getRandomQuoteFromArray
|
||||
};
|
||||
getRandomQuote,
|
||||
getAllQuotes,
|
||||
getQuotesCount: getCount,
|
||||
reloadQuotes,
|
||||
// For testing
|
||||
_createDefaultQuotes: createDefaultQuotes,
|
||||
_getQuotesFilePath: getQuotesFilePath,
|
||||
_parseYamlContent: parseYamlContent,
|
||||
_validateQuotesData: validateQuotesData,
|
||||
_extractQuotes: extractQuotes,
|
||||
_getRandomIndex: getRandomIndex,
|
||||
_getRandomQuoteFromArray: getRandomQuoteFromArray,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,411 +5,463 @@ const { Op } = require('sequelize');
|
|||
* Service for managing recurring tasks
|
||||
*/
|
||||
class RecurringTaskService {
|
||||
|
||||
/**
|
||||
* Generate new tasks from recurring task templates
|
||||
* @param {number} userId - Optional user ID to limit processing
|
||||
* @returns {Promise<Array>} Array of newly created tasks
|
||||
*/
|
||||
static async generateRecurringTasks(userId = null) {
|
||||
try {
|
||||
const whereClause = {
|
||||
recurrence_type: { [Op.ne]: 'none' },
|
||||
status: { [Op.ne]: Task.STATUS.ARCHIVED }
|
||||
};
|
||||
/**
|
||||
* Generate new tasks from recurring task templates
|
||||
* @param {number} userId - Optional user ID to limit processing
|
||||
* @returns {Promise<Array>} Array of newly created tasks
|
||||
*/
|
||||
static async generateRecurringTasks(userId = null) {
|
||||
try {
|
||||
const whereClause = {
|
||||
recurrence_type: { [Op.ne]: 'none' },
|
||||
status: { [Op.ne]: Task.STATUS.ARCHIVED },
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
whereClause.user_id = userId;
|
||||
}
|
||||
if (userId) {
|
||||
whereClause.user_id = userId;
|
||||
}
|
||||
|
||||
// Find all recurring tasks that need processing
|
||||
const recurringTasks = await Task.findAll({
|
||||
where: whereClause,
|
||||
order: [['last_generated_date', 'ASC']]
|
||||
});
|
||||
// Find all recurring tasks that need processing
|
||||
const recurringTasks = await Task.findAll({
|
||||
where: whereClause,
|
||||
order: [['last_generated_date', 'ASC']],
|
||||
});
|
||||
|
||||
const newTasks = [];
|
||||
const now = new Date();
|
||||
const newTasks = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const task of recurringTasks) {
|
||||
const generatedTasks = await this.processRecurringTask(task, now);
|
||||
newTasks.push(...generatedTasks);
|
||||
}
|
||||
for (const task of recurringTasks) {
|
||||
const generatedTasks = await this.processRecurringTask(
|
||||
task,
|
||||
now
|
||||
);
|
||||
newTasks.push(...generatedTasks);
|
||||
}
|
||||
|
||||
return newTasks;
|
||||
} catch (error) {
|
||||
console.error('Error generating recurring tasks:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single recurring task and generate new instances if needed
|
||||
* @param {Object} task - The recurring task template
|
||||
* @param {Date} now - Current timestamp
|
||||
* @returns {Promise<Array>} Array of newly created task instances
|
||||
*/
|
||||
static async processRecurringTask(task, now) {
|
||||
const newTasks = [];
|
||||
|
||||
// Skip if recurrence has ended
|
||||
if (task.recurrence_end_date && now > task.recurrence_end_date) {
|
||||
return newTasks;
|
||||
}
|
||||
|
||||
let nextDueDate = this.calculateNextDueDate(task, now);
|
||||
|
||||
// Generate tasks up to current date
|
||||
while (nextDueDate && nextDueDate <= now) {
|
||||
// Check if this due date already has a task instance
|
||||
const existingTask = await Task.findOne({
|
||||
where: {
|
||||
user_id: task.user_id,
|
||||
name: task.name,
|
||||
due_date: nextDueDate,
|
||||
project_id: task.project_id
|
||||
return newTasks;
|
||||
} catch (error) {
|
||||
console.error('Error generating recurring tasks:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingTask) {
|
||||
const newTask = await this.createTaskInstance(task, nextDueDate);
|
||||
newTasks.push(newTask);
|
||||
}
|
||||
|
||||
// Update last generated date
|
||||
task.last_generated_date = nextDueDate;
|
||||
await task.save();
|
||||
|
||||
// Calculate next due date
|
||||
nextDueDate = this.calculateNextDueDate(task, nextDueDate);
|
||||
|
||||
// Safety check to prevent infinite loops
|
||||
if (newTasks.length > 100) {
|
||||
console.warn(`Generated 100+ tasks for recurring task ${task.id}, stopping to prevent overflow`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return newTasks;
|
||||
}
|
||||
/**
|
||||
* Process a single recurring task and generate new instances if needed
|
||||
* @param {Object} task - The recurring task template
|
||||
* @param {Date} now - Current timestamp
|
||||
* @returns {Promise<Array>} Array of newly created task instances
|
||||
*/
|
||||
static async processRecurringTask(task, now) {
|
||||
const newTasks = [];
|
||||
|
||||
/**
|
||||
* Create a new task instance from a recurring task template
|
||||
* @param {Object} template - The recurring task template
|
||||
* @param {Date} dueDate - Due date for the new task instance
|
||||
* @returns {Promise<Object>} The newly created task
|
||||
*/
|
||||
static async createTaskInstance(template, dueDate) {
|
||||
const taskData = {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
due_date: dueDate,
|
||||
today: false,
|
||||
priority: template.priority,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
note: template.note,
|
||||
user_id: template.user_id,
|
||||
project_id: template.project_id,
|
||||
recurrence_type: 'none', // Instances are not recurring themselves
|
||||
recurring_parent_id: template.id // Link to the original recurring task
|
||||
};
|
||||
// Skip if recurrence has ended
|
||||
if (task.recurrence_end_date && now > task.recurrence_end_date) {
|
||||
return newTasks;
|
||||
}
|
||||
|
||||
return await Task.create(taskData);
|
||||
}
|
||||
let nextDueDate = this.calculateNextDueDate(task, now);
|
||||
|
||||
/**
|
||||
* Calculate the next due date for a recurring task
|
||||
* @param {Object} task - The recurring task
|
||||
* @param {Date} fromDate - Date to calculate from
|
||||
* @returns {Date|null} Next due date or null if no more recurrences
|
||||
*/
|
||||
static calculateNextDueDate(task, fromDate) {
|
||||
// Handle invalid inputs
|
||||
if (!task || !task.recurrence_type || !fromDate || isNaN(fromDate.getTime())) {
|
||||
return null;
|
||||
// Generate tasks up to current date
|
||||
while (nextDueDate && nextDueDate <= now) {
|
||||
// Check if this due date already has a task instance
|
||||
const existingTask = await Task.findOne({
|
||||
where: {
|
||||
user_id: task.user_id,
|
||||
name: task.name,
|
||||
due_date: nextDueDate,
|
||||
project_id: task.project_id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingTask) {
|
||||
const newTask = await this.createTaskInstance(
|
||||
task,
|
||||
nextDueDate
|
||||
);
|
||||
newTasks.push(newTask);
|
||||
}
|
||||
|
||||
// Update last generated date
|
||||
task.last_generated_date = nextDueDate;
|
||||
await task.save();
|
||||
|
||||
// Calculate next due date
|
||||
nextDueDate = this.calculateNextDueDate(task, nextDueDate);
|
||||
|
||||
// Safety check to prevent infinite loops
|
||||
if (newTasks.length > 100) {
|
||||
console.warn(
|
||||
`Generated 100+ tasks for recurring task ${task.id}, stopping to prevent overflow`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return newTasks;
|
||||
}
|
||||
|
||||
const baseDate = task.completion_based ?
|
||||
(task.last_generated_date || task.created_at) :
|
||||
(task.due_date || task.created_at);
|
||||
|
||||
// If no base date is available, use fromDate
|
||||
const startDate = baseDate ?
|
||||
new Date(Math.max(fromDate.getTime(), baseDate.getTime())) :
|
||||
new Date(fromDate.getTime());
|
||||
|
||||
switch (task.recurrence_type) {
|
||||
case 'daily':
|
||||
return this.calculateDailyRecurrence(startDate, task.recurrence_interval || 1);
|
||||
|
||||
case 'weekly':
|
||||
return this.calculateWeeklyRecurrence(startDate, task.recurrence_interval || 1, task.recurrence_weekday);
|
||||
|
||||
case 'monthly':
|
||||
return this.calculateMonthlyRecurrence(startDate, task.recurrence_interval || 1, task.recurrence_month_day);
|
||||
|
||||
case 'monthly_weekday':
|
||||
return this.calculateMonthlyWeekdayRecurrence(
|
||||
startDate,
|
||||
task.recurrence_interval || 1,
|
||||
task.recurrence_weekday,
|
||||
task.recurrence_week_of_month
|
||||
/**
|
||||
* Create a new task instance from a recurring task template
|
||||
* @param {Object} template - The recurring task template
|
||||
* @param {Date} dueDate - Due date for the new task instance
|
||||
* @returns {Promise<Object>} The newly created task
|
||||
*/
|
||||
static async createTaskInstance(template, dueDate) {
|
||||
const taskData = {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
due_date: dueDate,
|
||||
today: false,
|
||||
priority: template.priority,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
note: template.note,
|
||||
user_id: template.user_id,
|
||||
project_id: template.project_id,
|
||||
recurrence_type: 'none', // Instances are not recurring themselves
|
||||
recurring_parent_id: template.id, // Link to the original recurring task
|
||||
};
|
||||
|
||||
return await Task.create(taskData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the next due date for a recurring task
|
||||
* @param {Object} task - The recurring task
|
||||
* @param {Date} fromDate - Date to calculate from
|
||||
* @returns {Date|null} Next due date or null if no more recurrences
|
||||
*/
|
||||
static calculateNextDueDate(task, fromDate) {
|
||||
// Handle invalid inputs
|
||||
if (
|
||||
!task ||
|
||||
!task.recurrence_type ||
|
||||
!fromDate ||
|
||||
isNaN(fromDate.getTime())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseDate = task.completion_based
|
||||
? task.last_generated_date || task.created_at
|
||||
: task.due_date || task.created_at;
|
||||
|
||||
// If no base date is available, use fromDate
|
||||
const startDate = baseDate
|
||||
? new Date(Math.max(fromDate.getTime(), baseDate.getTime()))
|
||||
: new Date(fromDate.getTime());
|
||||
|
||||
switch (task.recurrence_type) {
|
||||
case 'daily':
|
||||
return this.calculateDailyRecurrence(
|
||||
startDate,
|
||||
task.recurrence_interval || 1
|
||||
);
|
||||
|
||||
case 'weekly':
|
||||
return this.calculateWeeklyRecurrence(
|
||||
startDate,
|
||||
task.recurrence_interval || 1,
|
||||
task.recurrence_weekday
|
||||
);
|
||||
|
||||
case 'monthly':
|
||||
return this.calculateMonthlyRecurrence(
|
||||
startDate,
|
||||
task.recurrence_interval || 1,
|
||||
task.recurrence_month_day
|
||||
);
|
||||
|
||||
case 'monthly_weekday':
|
||||
return this.calculateMonthlyWeekdayRecurrence(
|
||||
startDate,
|
||||
task.recurrence_interval || 1,
|
||||
task.recurrence_weekday,
|
||||
task.recurrence_week_of_month
|
||||
);
|
||||
|
||||
case 'monthly_last_day':
|
||||
return this.calculateMonthlyLastDayRecurrence(
|
||||
startDate,
|
||||
task.recurrence_interval || 1
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next daily recurrence
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Days between recurrences
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateDailyRecurrence(fromDate, interval) {
|
||||
const nextDate = new Date(fromDate);
|
||||
nextDate.setDate(nextDate.getDate() + interval);
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next weekly recurrence
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Weeks between recurrences
|
||||
* @param {number} weekday - Target day of week (0=Sunday, 6=Saturday)
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateWeeklyRecurrence(fromDate, interval, weekday) {
|
||||
const nextDate = new Date(fromDate);
|
||||
|
||||
if (weekday !== null && weekday !== undefined) {
|
||||
// Find next occurrence of the specified weekday
|
||||
const currentWeekday = nextDate.getDay();
|
||||
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;
|
||||
|
||||
if (
|
||||
daysUntilTarget === 0 &&
|
||||
nextDate.getTime() === fromDate.getTime()
|
||||
) {
|
||||
// If today is the target weekday and we're calculating from today, add interval weeks
|
||||
nextDate.setDate(nextDate.getDate() + interval * 7);
|
||||
} else {
|
||||
nextDate.setDate(nextDate.getDate() + daysUntilTarget);
|
||||
if (nextDate <= fromDate) {
|
||||
nextDate.setDate(nextDate.getDate() + interval * 7);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No specific weekday, just add interval weeks
|
||||
nextDate.setDate(nextDate.getDate() + interval * 7);
|
||||
}
|
||||
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next monthly recurrence on specific day
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Months between recurrences
|
||||
* @param {number} dayOfMonth - Target day of month (1-31)
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateMonthlyRecurrence(fromDate, interval, dayOfMonth) {
|
||||
const nextDate = new Date(fromDate);
|
||||
const targetDay = dayOfMonth || fromDate.getUTCDate();
|
||||
|
||||
// Move to target month
|
||||
const targetMonth = nextDate.getUTCMonth() + interval;
|
||||
const targetYear =
|
||||
nextDate.getUTCFullYear() + Math.floor(targetMonth / 12);
|
||||
const finalMonth = targetMonth % 12;
|
||||
|
||||
// Get the max day for the target month
|
||||
const maxDay = new Date(
|
||||
Date.UTC(targetYear, finalMonth + 1, 0)
|
||||
).getUTCDate();
|
||||
const finalDay = Math.min(targetDay, maxDay);
|
||||
|
||||
// Create the new date
|
||||
const result = new Date(
|
||||
Date.UTC(
|
||||
targetYear,
|
||||
finalMonth,
|
||||
finalDay,
|
||||
fromDate.getUTCHours(),
|
||||
fromDate.getUTCMinutes(),
|
||||
fromDate.getUTCSeconds(),
|
||||
fromDate.getUTCMilliseconds()
|
||||
)
|
||||
);
|
||||
|
||||
case 'monthly_last_day':
|
||||
return this.calculateMonthlyLastDayRecurrence(startDate, task.recurrence_interval || 1);
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next daily recurrence
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Days between recurrences
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateDailyRecurrence(fromDate, interval) {
|
||||
const nextDate = new Date(fromDate);
|
||||
nextDate.setDate(nextDate.getDate() + interval);
|
||||
return nextDate;
|
||||
}
|
||||
/**
|
||||
* Calculate next monthly recurrence on specific weekday of month
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Months between recurrences
|
||||
* @param {number} weekday - Target weekday (0=Sunday, 6=Saturday)
|
||||
* @param {number} weekOfMonth - Which occurrence in month (1-5)
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateMonthlyWeekdayRecurrence(
|
||||
fromDate,
|
||||
interval,
|
||||
weekday,
|
||||
weekOfMonth
|
||||
) {
|
||||
const nextDate = new Date(fromDate);
|
||||
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
|
||||
|
||||
/**
|
||||
* Calculate next weekly recurrence
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Weeks between recurrences
|
||||
* @param {number} weekday - Target day of week (0=Sunday, 6=Saturday)
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateWeeklyRecurrence(fromDate, interval, weekday) {
|
||||
const nextDate = new Date(fromDate);
|
||||
|
||||
if (weekday !== null && weekday !== undefined) {
|
||||
// Find next occurrence of the specified weekday
|
||||
const currentWeekday = nextDate.getDay();
|
||||
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;
|
||||
|
||||
if (daysUntilTarget === 0 && nextDate.getTime() === fromDate.getTime()) {
|
||||
// If today is the target weekday and we're calculating from today, add interval weeks
|
||||
nextDate.setDate(nextDate.getDate() + (interval * 7));
|
||||
} else {
|
||||
nextDate.setDate(nextDate.getDate() + daysUntilTarget);
|
||||
if (nextDate <= fromDate) {
|
||||
nextDate.setDate(nextDate.getDate() + (interval * 7));
|
||||
// Find the first day of the month
|
||||
const firstOfMonth = new Date(
|
||||
Date.UTC(nextDate.getUTCFullYear(), nextDate.getUTCMonth(), 1)
|
||||
);
|
||||
const firstWeekday = firstOfMonth.getUTCDay();
|
||||
|
||||
// Calculate the first occurrence of the target weekday
|
||||
const daysToAdd = (weekday - firstWeekday + 7) % 7;
|
||||
const firstOccurrence = new Date(firstOfMonth);
|
||||
firstOccurrence.setUTCDate(1 + daysToAdd);
|
||||
|
||||
// Add weeks to get to the target week of month
|
||||
const targetDate = new Date(firstOccurrence);
|
||||
targetDate.setUTCDate(
|
||||
firstOccurrence.getUTCDate() + (weekOfMonth - 1) * 7
|
||||
);
|
||||
|
||||
// Make sure we're still in the same month
|
||||
if (targetDate.getUTCMonth() !== nextDate.getUTCMonth()) {
|
||||
// Week doesn't exist in this month, use last occurrence
|
||||
targetDate.setUTCDate(targetDate.getUTCDate() - 7);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No specific weekday, just add interval weeks
|
||||
nextDate.setDate(nextDate.getDate() + (interval * 7));
|
||||
}
|
||||
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next monthly recurrence on specific day
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Months between recurrences
|
||||
* @param {number} dayOfMonth - Target day of month (1-31)
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateMonthlyRecurrence(fromDate, interval, dayOfMonth) {
|
||||
const nextDate = new Date(fromDate);
|
||||
const targetDay = dayOfMonth || fromDate.getUTCDate();
|
||||
|
||||
// Move to target month
|
||||
const targetMonth = nextDate.getUTCMonth() + interval;
|
||||
const targetYear = nextDate.getUTCFullYear() + Math.floor(targetMonth / 12);
|
||||
const finalMonth = targetMonth % 12;
|
||||
|
||||
// Get the max day for the target month
|
||||
const maxDay = new Date(Date.UTC(targetYear, finalMonth + 1, 0)).getUTCDate();
|
||||
const finalDay = Math.min(targetDay, maxDay);
|
||||
|
||||
// Create the new date
|
||||
const result = new Date(Date.UTC(
|
||||
targetYear,
|
||||
finalMonth,
|
||||
finalDay,
|
||||
fromDate.getUTCHours(),
|
||||
fromDate.getUTCMinutes(),
|
||||
fromDate.getUTCSeconds(),
|
||||
fromDate.getUTCMilliseconds()
|
||||
));
|
||||
|
||||
return result;
|
||||
}
|
||||
// Preserve the original time
|
||||
targetDate.setUTCHours(
|
||||
fromDate.getUTCHours(),
|
||||
fromDate.getUTCMinutes(),
|
||||
fromDate.getUTCSeconds(),
|
||||
fromDate.getUTCMilliseconds()
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculate next monthly recurrence on specific weekday of month
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Months between recurrences
|
||||
* @param {number} weekday - Target weekday (0=Sunday, 6=Saturday)
|
||||
* @param {number} weekOfMonth - Which occurrence in month (1-5)
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateMonthlyWeekdayRecurrence(fromDate, interval, weekday, weekOfMonth) {
|
||||
const nextDate = new Date(fromDate);
|
||||
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
|
||||
|
||||
// Find the first day of the month
|
||||
const firstOfMonth = new Date(Date.UTC(nextDate.getUTCFullYear(), nextDate.getUTCMonth(), 1));
|
||||
const firstWeekday = firstOfMonth.getUTCDay();
|
||||
|
||||
// Calculate the first occurrence of the target weekday
|
||||
const daysToAdd = (weekday - firstWeekday + 7) % 7;
|
||||
const firstOccurrence = new Date(firstOfMonth);
|
||||
firstOccurrence.setUTCDate(1 + daysToAdd);
|
||||
|
||||
// Add weeks to get to the target week of month
|
||||
const targetDate = new Date(firstOccurrence);
|
||||
targetDate.setUTCDate(firstOccurrence.getUTCDate() + ((weekOfMonth - 1) * 7));
|
||||
|
||||
// Make sure we're still in the same month
|
||||
if (targetDate.getUTCMonth() !== nextDate.getUTCMonth()) {
|
||||
// Week doesn't exist in this month, use last occurrence
|
||||
targetDate.setUTCDate(targetDate.getUTCDate() - 7);
|
||||
}
|
||||
|
||||
// Preserve the original time
|
||||
targetDate.setUTCHours(fromDate.getUTCHours(), fromDate.getUTCMinutes(), fromDate.getUTCSeconds(), fromDate.getUTCMilliseconds());
|
||||
|
||||
return targetDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next monthly recurrence on last day of month
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Months between recurrences
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateMonthlyLastDayRecurrence(fromDate, interval) {
|
||||
const nextDate = new Date(fromDate);
|
||||
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
|
||||
|
||||
// Set to last day of month
|
||||
nextDate.setUTCMonth(nextDate.getUTCMonth() + 1, 0);
|
||||
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get first weekday of month
|
||||
* @param {number} year - Year
|
||||
* @param {number} month - Month (0-11)
|
||||
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
|
||||
* @returns {Date} First occurrence of weekday in month
|
||||
*/
|
||||
static _getFirstWeekdayOfMonth(year, month, weekday) {
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const firstWeekday = firstOfMonth.getDay();
|
||||
const daysToAdd = (weekday - firstWeekday + 7) % 7;
|
||||
return new Date(year, month, 1 + daysToAdd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get last weekday of month
|
||||
* @param {number} year - Year
|
||||
* @param {number} month - Month (0-11)
|
||||
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
|
||||
* @returns {Date} Last occurrence of weekday in month
|
||||
*/
|
||||
static _getLastWeekdayOfMonth(year, month, weekday) {
|
||||
const lastOfMonth = new Date(year, month + 1, 0);
|
||||
const lastWeekday = lastOfMonth.getDay();
|
||||
const daysToSubtract = (lastWeekday - weekday + 7) % 7;
|
||||
return new Date(year, month, lastOfMonth.getDate() - daysToSubtract);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get nth weekday of month
|
||||
* @param {number} year - Year
|
||||
* @param {number} month - Month (0-11)
|
||||
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
|
||||
* @param {number} n - Which occurrence (1-5)
|
||||
* @returns {Date} Nth occurrence of weekday in month
|
||||
*/
|
||||
static _getNthWeekdayOfMonth(year, month, weekday, n) {
|
||||
const firstOccurrence = this._getFirstWeekdayOfMonth(year, month, weekday);
|
||||
const targetDate = new Date(firstOccurrence);
|
||||
targetDate.setDate(firstOccurrence.getDate() + ((n - 1) * 7));
|
||||
|
||||
// If target date is in next month, return null
|
||||
if (targetDate.getMonth() !== month) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return targetDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if next task should be generated
|
||||
* @param {Object} task - The recurring task
|
||||
* @param {Date} nextDate - Next due date
|
||||
* @returns {boolean} Whether to generate next task
|
||||
*/
|
||||
static _shouldGenerateNextTask(task, nextDate) {
|
||||
if (!task.recurrence_end_date) {
|
||||
return true;
|
||||
}
|
||||
return nextDate < task.recurrence_end_date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion for recurring tasks
|
||||
* @param {Object} task - The completed task
|
||||
* @returns {Promise<Object|null>} Next task instance if applicable
|
||||
*/
|
||||
static async handleTaskCompletion(task) {
|
||||
// Check if the completed task itself is a recurring task
|
||||
if (!task.recurrence_type || task.recurrence_type === 'none') {
|
||||
return null;
|
||||
return targetDate;
|
||||
}
|
||||
|
||||
// Only generate next task if completion_based is true
|
||||
if (!task.completion_based) {
|
||||
return null;
|
||||
/**
|
||||
* Calculate next monthly recurrence on last day of month
|
||||
* @param {Date} fromDate - Starting date
|
||||
* @param {number} interval - Months between recurrences
|
||||
* @returns {Date} Next due date
|
||||
*/
|
||||
static calculateMonthlyLastDayRecurrence(fromDate, interval) {
|
||||
const nextDate = new Date(fromDate);
|
||||
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
|
||||
|
||||
// Set to last day of month
|
||||
nextDate.setUTCMonth(nextDate.getUTCMonth() + 1, 0);
|
||||
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
// Update the task's last generated date to completion date
|
||||
task.last_generated_date = new Date();
|
||||
await task.save();
|
||||
|
||||
// For completion-based tasks, create the next instance immediately
|
||||
const nextDueDate = this.calculateNextDueDate(task, new Date());
|
||||
|
||||
if (!nextDueDate) {
|
||||
return null;
|
||||
/**
|
||||
* Helper function to get first weekday of month
|
||||
* @param {number} year - Year
|
||||
* @param {number} month - Month (0-11)
|
||||
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
|
||||
* @returns {Date} First occurrence of weekday in month
|
||||
*/
|
||||
static _getFirstWeekdayOfMonth(year, month, weekday) {
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const firstWeekday = firstOfMonth.getDay();
|
||||
const daysToAdd = (weekday - firstWeekday + 7) % 7;
|
||||
return new Date(year, month, 1 + daysToAdd);
|
||||
}
|
||||
|
||||
// Check if this due date already has a task instance
|
||||
const whereClause = {
|
||||
user_id: task.user_id,
|
||||
name: task.name,
|
||||
due_date: nextDueDate
|
||||
};
|
||||
|
||||
// Only add project_id to where clause if it's not null/undefined
|
||||
if (task.project_id !== null && task.project_id !== undefined) {
|
||||
whereClause.project_id = task.project_id;
|
||||
} else {
|
||||
whereClause.project_id = null;
|
||||
}
|
||||
|
||||
const existingTask = await Task.findOne({
|
||||
where: whereClause
|
||||
});
|
||||
|
||||
if (existingTask) {
|
||||
return null; // Task already exists for this date
|
||||
/**
|
||||
* Helper function to get last weekday of month
|
||||
* @param {number} year - Year
|
||||
* @param {number} month - Month (0-11)
|
||||
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
|
||||
* @returns {Date} Last occurrence of weekday in month
|
||||
*/
|
||||
static _getLastWeekdayOfMonth(year, month, weekday) {
|
||||
const lastOfMonth = new Date(year, month + 1, 0);
|
||||
const lastWeekday = lastOfMonth.getDay();
|
||||
const daysToSubtract = (lastWeekday - weekday + 7) % 7;
|
||||
return new Date(year, month, lastOfMonth.getDate() - daysToSubtract);
|
||||
}
|
||||
|
||||
// Create the next task instance
|
||||
const nextTask = await this.createTaskInstance(task, nextDueDate);
|
||||
return nextTask;
|
||||
}
|
||||
/**
|
||||
* Helper function to get nth weekday of month
|
||||
* @param {number} year - Year
|
||||
* @param {number} month - Month (0-11)
|
||||
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
|
||||
* @param {number} n - Which occurrence (1-5)
|
||||
* @returns {Date} Nth occurrence of weekday in month
|
||||
*/
|
||||
static _getNthWeekdayOfMonth(year, month, weekday, n) {
|
||||
const firstOccurrence = this._getFirstWeekdayOfMonth(
|
||||
year,
|
||||
month,
|
||||
weekday
|
||||
);
|
||||
const targetDate = new Date(firstOccurrence);
|
||||
targetDate.setDate(firstOccurrence.getDate() + (n - 1) * 7);
|
||||
|
||||
// If target date is in next month, return null
|
||||
if (targetDate.getMonth() !== month) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return targetDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if next task should be generated
|
||||
* @param {Object} task - The recurring task
|
||||
* @param {Date} nextDate - Next due date
|
||||
* @returns {boolean} Whether to generate next task
|
||||
*/
|
||||
static _shouldGenerateNextTask(task, nextDate) {
|
||||
if (!task.recurrence_end_date) {
|
||||
return true;
|
||||
}
|
||||
return nextDate < task.recurrence_end_date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task completion for recurring tasks
|
||||
* @param {Object} task - The completed task
|
||||
* @returns {Promise<Object|null>} Next task instance if applicable
|
||||
*/
|
||||
static async handleTaskCompletion(task) {
|
||||
// Check if the completed task itself is a recurring task
|
||||
if (!task.recurrence_type || task.recurrence_type === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only generate next task if completion_based is true
|
||||
if (!task.completion_based) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update the task's last generated date to completion date
|
||||
task.last_generated_date = new Date();
|
||||
await task.save();
|
||||
|
||||
// For completion-based tasks, create the next instance immediately
|
||||
const nextDueDate = this.calculateNextDueDate(task, new Date());
|
||||
|
||||
if (!nextDueDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this due date already has a task instance
|
||||
const whereClause = {
|
||||
user_id: task.user_id,
|
||||
name: task.name,
|
||||
due_date: nextDueDate,
|
||||
};
|
||||
|
||||
// Only add project_id to where clause if it's not null/undefined
|
||||
if (task.project_id !== null && task.project_id !== undefined) {
|
||||
whereClause.project_id = task.project_id;
|
||||
} else {
|
||||
whereClause.project_id = null;
|
||||
}
|
||||
|
||||
const existingTask = await Task.findOne({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
if (existingTask) {
|
||||
return null; // Task already exists for this date
|
||||
}
|
||||
|
||||
// Create the next task instance
|
||||
const nextTask = await this.createTaskInstance(task, nextDueDate);
|
||||
return nextTask;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RecurringTaskService;
|
||||
module.exports = RecurringTaskService;
|
||||
|
|
|
|||
|
|
@ -1,329 +1,417 @@
|
|||
const { TaskEvent } = require('../models');
|
||||
|
||||
class TaskEventService {
|
||||
/**
|
||||
* Log a task event
|
||||
* @param {Object} eventData - Event data
|
||||
* @param {number} eventData.taskId - Task ID
|
||||
* @param {number} eventData.userId - User ID
|
||||
* @param {string} eventData.eventType - Type of event
|
||||
* @param {string} eventData.fieldName - Field that changed (optional)
|
||||
* @param {any} eventData.oldValue - Old value (optional)
|
||||
* @param {any} eventData.newValue - New value (optional)
|
||||
* @param {Object} eventData.metadata - Additional metadata (optional)
|
||||
*/
|
||||
static async logEvent({ taskId, userId, eventType, fieldName = null, oldValue = null, newValue = null, metadata = {} }) {
|
||||
try {
|
||||
// Add source to metadata if not provided
|
||||
if (!metadata.source) {
|
||||
metadata.source = 'web';
|
||||
}
|
||||
|
||||
const event = await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
field_name: fieldName,
|
||||
old_value: oldValue ? { [fieldName || 'value']: oldValue } : null,
|
||||
new_value: newValue ? { [fieldName || 'value']: newValue } : null,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
return event;
|
||||
} catch (error) {
|
||||
console.error('Error logging task event:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task creation event
|
||||
*/
|
||||
static async logTaskCreated(taskId, userId, taskData, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'created',
|
||||
newValue: taskData,
|
||||
metadata: { ...metadata, action: 'task_created' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log status change event
|
||||
*/
|
||||
static async logStatusChange(taskId, userId, oldStatus, newStatus, metadata = {}) {
|
||||
const eventType = newStatus === 2 ? 'completed' :
|
||||
newStatus === 3 ? 'archived' :
|
||||
'status_changed';
|
||||
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType,
|
||||
fieldName: 'status',
|
||||
oldValue: oldStatus,
|
||||
newValue: newStatus,
|
||||
metadata: { ...metadata, action: 'status_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log priority change event
|
||||
*/
|
||||
static async logPriorityChange(taskId, userId, oldPriority, newPriority, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'priority_changed',
|
||||
fieldName: 'priority',
|
||||
oldValue: oldPriority,
|
||||
newValue: newPriority,
|
||||
metadata: { ...metadata, action: 'priority_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log due date change event
|
||||
*/
|
||||
static async logDueDateChange(taskId, userId, oldDueDate, newDueDate, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'due_date_changed',
|
||||
fieldName: 'due_date',
|
||||
oldValue: oldDueDate,
|
||||
newValue: newDueDate,
|
||||
metadata: { ...metadata, action: 'due_date_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log project change event
|
||||
*/
|
||||
static async logProjectChange(taskId, userId, oldProjectId, newProjectId, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'project_changed',
|
||||
fieldName: 'project_id',
|
||||
oldValue: oldProjectId,
|
||||
newValue: newProjectId,
|
||||
metadata: { ...metadata, action: 'project_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task name change event
|
||||
*/
|
||||
static async logNameChange(taskId, userId, oldName, newName, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'name_changed',
|
||||
fieldName: 'name',
|
||||
oldValue: oldName,
|
||||
newValue: newName,
|
||||
metadata: { ...metadata, action: 'name_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log description change event
|
||||
*/
|
||||
static async logDescriptionChange(taskId, userId, oldDescription, newDescription, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'description_changed',
|
||||
fieldName: 'description',
|
||||
oldValue: oldDescription,
|
||||
newValue: newDescription,
|
||||
metadata: { ...metadata, action: 'description_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log multiple field changes at once
|
||||
*/
|
||||
static async logTaskUpdate(taskId, userId, changes, metadata = {}) {
|
||||
const events = [];
|
||||
|
||||
for (const [fieldName, { oldValue, newValue }] of Object.entries(changes)) {
|
||||
// Skip if values are the same
|
||||
if (oldValue === newValue) continue;
|
||||
|
||||
let eventType;
|
||||
switch (fieldName) {
|
||||
case 'status':
|
||||
eventType = newValue === 2 ? 'completed' :
|
||||
newValue === 3 ? 'archived' :
|
||||
'status_changed';
|
||||
break;
|
||||
default:
|
||||
eventType = `${fieldName}_changed`;
|
||||
}
|
||||
|
||||
const event = await this.logEvent({
|
||||
/**
|
||||
* Log a task event
|
||||
* @param {Object} eventData - Event data
|
||||
* @param {number} eventData.taskId - Task ID
|
||||
* @param {number} eventData.userId - User ID
|
||||
* @param {string} eventData.eventType - Type of event
|
||||
* @param {string} eventData.fieldName - Field that changed (optional)
|
||||
* @param {any} eventData.oldValue - Old value (optional)
|
||||
* @param {any} eventData.newValue - New value (optional)
|
||||
* @param {Object} eventData.metadata - Additional metadata (optional)
|
||||
*/
|
||||
static async logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType,
|
||||
fieldName,
|
||||
oldValue,
|
||||
newValue,
|
||||
metadata: { ...metadata, action: 'bulk_update' }
|
||||
});
|
||||
fieldName = null,
|
||||
oldValue = null,
|
||||
newValue = null,
|
||||
metadata = {},
|
||||
}) {
|
||||
try {
|
||||
// Add source to metadata if not provided
|
||||
if (!metadata.source) {
|
||||
metadata.source = 'web';
|
||||
}
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
const event = await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
field_name: fieldName,
|
||||
old_value: oldValue
|
||||
? { [fieldName || 'value']: oldValue }
|
||||
: null,
|
||||
new_value: newValue
|
||||
? { [fieldName || 'value']: newValue }
|
||||
: null,
|
||||
metadata: metadata,
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task timeline (all events for a task)
|
||||
*/
|
||||
static async getTaskTimeline(taskId) {
|
||||
return await TaskEvent.findAll({
|
||||
where: { task_id: taskId },
|
||||
order: [['created_at', 'ASC']],
|
||||
include: [{
|
||||
model: require('../models').User,
|
||||
as: 'User',
|
||||
attributes: ['id', 'name', 'email']
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task completion metrics
|
||||
*/
|
||||
static async getTaskCompletionTime(taskId) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: ['status_changed', 'created', 'completed']
|
||||
},
|
||||
order: [['created_at', 'ASC']]
|
||||
});
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
// Find when task was started (moved to in_progress or created)
|
||||
const startEvent = events.find(e =>
|
||||
e.event_type === 'created' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
|
||||
);
|
||||
|
||||
// Find when task was completed
|
||||
const completedEvent = events.find(e =>
|
||||
e.event_type === 'completed' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
|
||||
);
|
||||
|
||||
if (!startEvent || !completedEvent) return null;
|
||||
|
||||
const startTime = new Date(startEvent.created_at);
|
||||
const endTime = new Date(completedEvent.created_at);
|
||||
|
||||
return {
|
||||
task_id: taskId,
|
||||
started_at: startTime,
|
||||
completed_at: endTime,
|
||||
duration_ms: endTime - startTime,
|
||||
duration_hours: (endTime - startTime) / (1000 * 60 * 60),
|
||||
duration_days: (endTime - startTime) / (1000 * 60 * 60 * 24)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user productivity metrics
|
||||
*/
|
||||
static async getUserProductivityMetrics(userId, startDate = null, endDate = null) {
|
||||
const whereClause = { user_id: userId };
|
||||
|
||||
if (startDate && endDate) {
|
||||
whereClause.created_at = {
|
||||
[require('sequelize').Op.between]: [startDate, endDate]
|
||||
};
|
||||
}
|
||||
|
||||
const events = await TaskEvent.findAll({
|
||||
where: whereClause,
|
||||
order: [['created_at', 'ASC']]
|
||||
});
|
||||
|
||||
// Calculate metrics
|
||||
const metrics = {
|
||||
total_events: events.length,
|
||||
tasks_created: events.filter(e => e.event_type === 'created').length,
|
||||
tasks_completed: events.filter(e => e.event_type === 'completed').length,
|
||||
status_changes: events.filter(e => e.event_type === 'status_changed').length,
|
||||
average_completion_time: null,
|
||||
completion_times: []
|
||||
};
|
||||
|
||||
// Calculate completion times for all completed tasks
|
||||
const completedTasks = events.filter(e => e.event_type === 'completed');
|
||||
const completionTimes = [];
|
||||
|
||||
for (const completedEvent of completedTasks) {
|
||||
const taskCompletion = await this.getTaskCompletionTime(completedEvent.task_id);
|
||||
if (taskCompletion) {
|
||||
completionTimes.push(taskCompletion);
|
||||
}
|
||||
}
|
||||
|
||||
if (completionTimes.length > 0) {
|
||||
const totalHours = completionTimes.reduce((sum, ct) => sum + ct.duration_hours, 0);
|
||||
metrics.average_completion_time = totalHours / completionTimes.length;
|
||||
metrics.completion_times = completionTimes;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task activity summary for a date range
|
||||
*/
|
||||
static async getTaskActivitySummary(userId, startDate, endDate) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
created_at: {
|
||||
[require('sequelize').Op.between]: [startDate, endDate]
|
||||
return event;
|
||||
} catch (error) {
|
||||
console.error('Error logging task event:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
attributes: [
|
||||
'event_type',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
[require('sequelize').fn('DATE', require('sequelize').col('created_at')), 'date']
|
||||
],
|
||||
group: ['event_type', 'date'],
|
||||
order: [['date', 'ASC']]
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
/**
|
||||
* Log task creation event
|
||||
*/
|
||||
static async logTaskCreated(taskId, userId, taskData, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'created',
|
||||
newValue: taskData,
|
||||
metadata: { ...metadata, action: 'task_created' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of how many times a task has been moved to today
|
||||
*/
|
||||
static async getTaskTodayMoveCount(taskId) {
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const count = await TaskEvent.count({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: 'today_changed',
|
||||
new_value: {
|
||||
[Op.like]: '%"today":true%'
|
||||
/**
|
||||
* Log status change event
|
||||
*/
|
||||
static async logStatusChange(
|
||||
taskId,
|
||||
userId,
|
||||
oldStatus,
|
||||
newStatus,
|
||||
metadata = {}
|
||||
) {
|
||||
const eventType =
|
||||
newStatus === 2
|
||||
? 'completed'
|
||||
: newStatus === 3
|
||||
? 'archived'
|
||||
: 'status_changed';
|
||||
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType,
|
||||
fieldName: 'status',
|
||||
oldValue: oldStatus,
|
||||
newValue: newStatus,
|
||||
metadata: { ...metadata, action: 'status_change' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log priority change event
|
||||
*/
|
||||
static async logPriorityChange(
|
||||
taskId,
|
||||
userId,
|
||||
oldPriority,
|
||||
newPriority,
|
||||
metadata = {}
|
||||
) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'priority_changed',
|
||||
fieldName: 'priority',
|
||||
oldValue: oldPriority,
|
||||
newValue: newPriority,
|
||||
metadata: { ...metadata, action: 'priority_change' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log due date change event
|
||||
*/
|
||||
static async logDueDateChange(
|
||||
taskId,
|
||||
userId,
|
||||
oldDueDate,
|
||||
newDueDate,
|
||||
metadata = {}
|
||||
) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'due_date_changed',
|
||||
fieldName: 'due_date',
|
||||
oldValue: oldDueDate,
|
||||
newValue: newDueDate,
|
||||
metadata: { ...metadata, action: 'due_date_change' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log project change event
|
||||
*/
|
||||
static async logProjectChange(
|
||||
taskId,
|
||||
userId,
|
||||
oldProjectId,
|
||||
newProjectId,
|
||||
metadata = {}
|
||||
) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'project_changed',
|
||||
fieldName: 'project_id',
|
||||
oldValue: oldProjectId,
|
||||
newValue: newProjectId,
|
||||
metadata: { ...metadata, action: 'project_change' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task name change event
|
||||
*/
|
||||
static async logNameChange(
|
||||
taskId,
|
||||
userId,
|
||||
oldName,
|
||||
newName,
|
||||
metadata = {}
|
||||
) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'name_changed',
|
||||
fieldName: 'name',
|
||||
oldValue: oldName,
|
||||
newValue: newName,
|
||||
metadata: { ...metadata, action: 'name_change' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log description change event
|
||||
*/
|
||||
static async logDescriptionChange(
|
||||
taskId,
|
||||
userId,
|
||||
oldDescription,
|
||||
newDescription,
|
||||
metadata = {}
|
||||
) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'description_changed',
|
||||
fieldName: 'description',
|
||||
oldValue: oldDescription,
|
||||
newValue: newDescription,
|
||||
metadata: { ...metadata, action: 'description_change' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log multiple field changes at once
|
||||
*/
|
||||
static async logTaskUpdate(taskId, userId, changes, metadata = {}) {
|
||||
const events = [];
|
||||
|
||||
for (const [fieldName, { oldValue, newValue }] of Object.entries(
|
||||
changes
|
||||
)) {
|
||||
// Skip if values are the same
|
||||
if (oldValue === newValue) continue;
|
||||
|
||||
let eventType;
|
||||
switch (fieldName) {
|
||||
case 'status':
|
||||
eventType =
|
||||
newValue === 2
|
||||
? 'completed'
|
||||
: newValue === 3
|
||||
? 'archived'
|
||||
: 'status_changed';
|
||||
break;
|
||||
default:
|
||||
eventType = `${fieldName}_changed`;
|
||||
}
|
||||
|
||||
const event = await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType,
|
||||
fieldName,
|
||||
oldValue,
|
||||
newValue,
|
||||
metadata: { ...metadata, action: 'bulk_update' },
|
||||
});
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task timeline (all events for a task)
|
||||
*/
|
||||
static async getTaskTimeline(taskId) {
|
||||
return await TaskEvent.findAll({
|
||||
where: { task_id: taskId },
|
||||
order: [['created_at', 'ASC']],
|
||||
include: [
|
||||
{
|
||||
model: require('../models').User,
|
||||
as: 'User',
|
||||
attributes: ['id', 'name', 'email'],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task completion metrics
|
||||
*/
|
||||
static async getTaskCompletionTime(taskId) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: ['status_changed', 'created', 'completed'],
|
||||
},
|
||||
order: [['created_at', 'ASC']],
|
||||
});
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
// Find when task was started (moved to in_progress or created)
|
||||
const startEvent = events.find(
|
||||
(e) =>
|
||||
e.event_type === 'created' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
|
||||
);
|
||||
|
||||
// Find when task was completed
|
||||
const completedEvent = events.find(
|
||||
(e) =>
|
||||
e.event_type === 'completed' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
|
||||
);
|
||||
|
||||
if (!startEvent || !completedEvent) return null;
|
||||
|
||||
const startTime = new Date(startEvent.created_at);
|
||||
const endTime = new Date(completedEvent.created_at);
|
||||
|
||||
return {
|
||||
task_id: taskId,
|
||||
started_at: startTime,
|
||||
completed_at: endTime,
|
||||
duration_ms: endTime - startTime,
|
||||
duration_hours: (endTime - startTime) / (1000 * 60 * 60),
|
||||
duration_days: (endTime - startTime) / (1000 * 60 * 60 * 24),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user productivity metrics
|
||||
*/
|
||||
static async getUserProductivityMetrics(
|
||||
userId,
|
||||
startDate = null,
|
||||
endDate = null
|
||||
) {
|
||||
const whereClause = { user_id: userId };
|
||||
|
||||
if (startDate && endDate) {
|
||||
whereClause.created_at = {
|
||||
[require('sequelize').Op.between]: [startDate, endDate],
|
||||
};
|
||||
}
|
||||
|
||||
const events = await TaskEvent.findAll({
|
||||
where: whereClause,
|
||||
order: [['created_at', 'ASC']],
|
||||
});
|
||||
|
||||
// Calculate metrics
|
||||
const metrics = {
|
||||
total_events: events.length,
|
||||
tasks_created: events.filter((e) => e.event_type === 'created')
|
||||
.length,
|
||||
tasks_completed: events.filter((e) => e.event_type === 'completed')
|
||||
.length,
|
||||
status_changes: events.filter(
|
||||
(e) => e.event_type === 'status_changed'
|
||||
).length,
|
||||
average_completion_time: null,
|
||||
completion_times: [],
|
||||
};
|
||||
|
||||
// Calculate completion times for all completed tasks
|
||||
const completedTasks = events.filter(
|
||||
(e) => e.event_type === 'completed'
|
||||
);
|
||||
const completionTimes = [];
|
||||
|
||||
for (const completedEvent of completedTasks) {
|
||||
const taskCompletion = await this.getTaskCompletionTime(
|
||||
completedEvent.task_id
|
||||
);
|
||||
if (taskCompletion) {
|
||||
completionTimes.push(taskCompletion);
|
||||
}
|
||||
}
|
||||
|
||||
if (completionTimes.length > 0) {
|
||||
const totalHours = completionTimes.reduce(
|
||||
(sum, ct) => sum + ct.duration_hours,
|
||||
0
|
||||
);
|
||||
metrics.average_completion_time =
|
||||
totalHours / completionTimes.length;
|
||||
metrics.completion_times = completionTimes;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task activity summary for a date range
|
||||
*/
|
||||
static async getTaskActivitySummary(userId, startDate, endDate) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
created_at: {
|
||||
[require('sequelize').Op.between]: [startDate, endDate],
|
||||
},
|
||||
},
|
||||
attributes: [
|
||||
'event_type',
|
||||
[
|
||||
require('sequelize').fn(
|
||||
'COUNT',
|
||||
require('sequelize').col('id')
|
||||
),
|
||||
'count',
|
||||
],
|
||||
[
|
||||
require('sequelize').fn(
|
||||
'DATE',
|
||||
require('sequelize').col('created_at')
|
||||
),
|
||||
'date',
|
||||
],
|
||||
],
|
||||
group: ['event_type', 'date'],
|
||||
order: [['date', 'ASC']],
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of how many times a task has been moved to today
|
||||
*/
|
||||
static async getTaskTodayMoveCount(taskId) {
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const count = await TaskEvent.count({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: 'today_changed',
|
||||
new_value: {
|
||||
[Op.like]: '%"today":true%',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskEventService;
|
||||
module.exports = TaskEventService;
|
||||
|
|
|
|||
|
|
@ -5,187 +5,197 @@ const RecurringTaskService = require('./recurringTaskService');
|
|||
|
||||
// Create scheduler state
|
||||
const createSchedulerState = () => ({
|
||||
jobs: new Map(),
|
||||
isInitialized: false
|
||||
jobs: new Map(),
|
||||
isInitialized: false,
|
||||
});
|
||||
|
||||
// Global mutable state (will be managed functionally)
|
||||
let schedulerState = createSchedulerState();
|
||||
|
||||
// Check if scheduler should be disabled
|
||||
const shouldDisableScheduler = () =>
|
||||
process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true';
|
||||
const shouldDisableScheduler = () =>
|
||||
process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true';
|
||||
|
||||
// Create job configuration
|
||||
const createJobConfig = () => ({
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
scheduled: false,
|
||||
timezone: 'UTC',
|
||||
});
|
||||
|
||||
// Create cron expressions
|
||||
const getCronExpression = (frequency) => {
|
||||
const expressions = {
|
||||
daily: '0 7 * * *',
|
||||
weekdays: '0 7 * * 1-5',
|
||||
weekly: '0 7 * * 1',
|
||||
'1h': '0 * * * *',
|
||||
'2h': '0 */2 * * *',
|
||||
'4h': '0 */4 * * *',
|
||||
'8h': '0 */8 * * *',
|
||||
'12h': '0 */12 * * *',
|
||||
recurring_tasks: '0 6 * * *' // Daily at 6 AM for recurring task generation
|
||||
};
|
||||
return expressions[frequency];
|
||||
const expressions = {
|
||||
daily: '0 7 * * *',
|
||||
weekdays: '0 7 * * 1-5',
|
||||
weekly: '0 7 * * 1',
|
||||
'1h': '0 * * * *',
|
||||
'2h': '0 */2 * * *',
|
||||
'4h': '0 */4 * * *',
|
||||
'8h': '0 */8 * * *',
|
||||
'12h': '0 */12 * * *',
|
||||
recurring_tasks: '0 6 * * *', // Daily at 6 AM for recurring task generation
|
||||
};
|
||||
return expressions[frequency];
|
||||
};
|
||||
|
||||
// Create job handler
|
||||
const createJobHandler = (frequency) => async () => {
|
||||
if (frequency === 'recurring_tasks') {
|
||||
await processRecurringTasks();
|
||||
} else {
|
||||
await processSummariesForFrequency(frequency);
|
||||
}
|
||||
if (frequency === 'recurring_tasks') {
|
||||
await processRecurringTasks();
|
||||
} else {
|
||||
await processSummariesForFrequency(frequency);
|
||||
}
|
||||
};
|
||||
|
||||
// Create job entries
|
||||
const createJobEntries = () => {
|
||||
const frequencies = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h', 'recurring_tasks'];
|
||||
|
||||
return frequencies.map(frequency => {
|
||||
const cronExpression = getCronExpression(frequency);
|
||||
const jobHandler = createJobHandler(frequency);
|
||||
const jobConfig = createJobConfig();
|
||||
const job = cron.schedule(cronExpression, jobHandler, jobConfig);
|
||||
|
||||
return [frequency, job];
|
||||
});
|
||||
const frequencies = [
|
||||
'daily',
|
||||
'weekdays',
|
||||
'weekly',
|
||||
'1h',
|
||||
'2h',
|
||||
'4h',
|
||||
'8h',
|
||||
'12h',
|
||||
'recurring_tasks',
|
||||
];
|
||||
|
||||
return frequencies.map((frequency) => {
|
||||
const cronExpression = getCronExpression(frequency);
|
||||
const jobHandler = createJobHandler(frequency);
|
||||
const jobConfig = createJobConfig();
|
||||
const job = cron.schedule(cronExpression, jobHandler, jobConfig);
|
||||
|
||||
return [frequency, job];
|
||||
});
|
||||
};
|
||||
|
||||
// Start all jobs
|
||||
const startJobs = (jobs) => {
|
||||
jobs.forEach((job, frequency) => {
|
||||
job.start();
|
||||
});
|
||||
jobs.forEach((job, frequency) => {
|
||||
job.start();
|
||||
});
|
||||
};
|
||||
|
||||
// Stop all jobs
|
||||
const stopJobs = (jobs) => {
|
||||
jobs.forEach((job, frequency) => {
|
||||
job.stop();
|
||||
});
|
||||
jobs.forEach((job, frequency) => {
|
||||
job.stop();
|
||||
});
|
||||
};
|
||||
|
||||
// Side effect function to fetch users for frequency
|
||||
const fetchUsersForFrequency = async (frequency) => {
|
||||
return await User.findAll({
|
||||
where: {
|
||||
telegram_bot_token: { [require('sequelize').Op.ne]: null },
|
||||
telegram_chat_id: { [require('sequelize').Op.ne]: null },
|
||||
task_summary_enabled: true,
|
||||
task_summary_frequency: frequency
|
||||
}
|
||||
});
|
||||
return await User.findAll({
|
||||
where: {
|
||||
telegram_bot_token: { [require('sequelize').Op.ne]: null },
|
||||
telegram_chat_id: { [require('sequelize').Op.ne]: null },
|
||||
task_summary_enabled: true,
|
||||
task_summary_frequency: frequency,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Side effect function to send summary to user
|
||||
const sendSummaryToUser = async (userId, frequency) => {
|
||||
try {
|
||||
const success = await TaskSummaryService.sendSummaryToUser(userId);
|
||||
return success;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const success = await TaskSummaryService.sendSummaryToUser(userId);
|
||||
return success;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to process summaries for frequency (contains side effects)
|
||||
const processSummariesForFrequency = async (frequency) => {
|
||||
try {
|
||||
const users = await fetchUsersForFrequency(frequency);
|
||||
try {
|
||||
const users = await fetchUsersForFrequency(frequency);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
users.map(user => sendSummaryToUser(user.id, frequency))
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
users.map((user) => sendSummaryToUser(user.id, frequency))
|
||||
);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to process recurring tasks (contains side effects)
|
||||
const processRecurringTasks = async () => {
|
||||
try {
|
||||
const newTasks = await RecurringTaskService.generateRecurringTasks();
|
||||
return newTasks;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const newTasks = await RecurringTaskService.generateRecurringTasks();
|
||||
return newTasks;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to initialize scheduler (contains side effects)
|
||||
const initialize = async () => {
|
||||
if (schedulerState.isInitialized) {
|
||||
if (schedulerState.isInitialized) {
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
if (shouldDisableScheduler()) {
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
// Create job entries
|
||||
const jobEntries = createJobEntries();
|
||||
const jobs = new Map(jobEntries);
|
||||
|
||||
// Start all jobs
|
||||
startJobs(jobs);
|
||||
|
||||
// Update state immutably
|
||||
schedulerState = {
|
||||
jobs,
|
||||
isInitialized: true,
|
||||
};
|
||||
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
if (shouldDisableScheduler()) {
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
// Create job entries
|
||||
const jobEntries = createJobEntries();
|
||||
const jobs = new Map(jobEntries);
|
||||
|
||||
// Start all jobs
|
||||
startJobs(jobs);
|
||||
|
||||
// Update state immutably
|
||||
schedulerState = {
|
||||
jobs,
|
||||
isInitialized: true
|
||||
};
|
||||
|
||||
return schedulerState;
|
||||
};
|
||||
|
||||
// Function to stop scheduler (contains side effects)
|
||||
const stop = async () => {
|
||||
if (!schedulerState.isInitialized) {
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
// Stop all jobs
|
||||
stopJobs(schedulerState.jobs);
|
||||
if (!schedulerState.isInitialized) {
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
// Reset state immutably
|
||||
schedulerState = createSchedulerState();
|
||||
|
||||
return schedulerState;
|
||||
// Stop all jobs
|
||||
stopJobs(schedulerState.jobs);
|
||||
|
||||
// Reset state immutably
|
||||
schedulerState = createSchedulerState();
|
||||
|
||||
return schedulerState;
|
||||
};
|
||||
|
||||
// Function to restart scheduler
|
||||
const restart = async () => {
|
||||
await stop();
|
||||
return await initialize();
|
||||
await stop();
|
||||
return await initialize();
|
||||
};
|
||||
|
||||
// Get scheduler status
|
||||
const getStatus = () => ({
|
||||
initialized: schedulerState.isInitialized,
|
||||
jobCount: schedulerState.jobs.size,
|
||||
jobs: Array.from(schedulerState.jobs.keys())
|
||||
initialized: schedulerState.isInitialized,
|
||||
jobCount: schedulerState.jobs.size,
|
||||
jobs: Array.from(schedulerState.jobs.keys()),
|
||||
});
|
||||
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
initialize,
|
||||
stop,
|
||||
restart,
|
||||
getStatus,
|
||||
processSummariesForFrequency,
|
||||
processRecurringTasks,
|
||||
// For testing
|
||||
_createSchedulerState: createSchedulerState,
|
||||
_shouldDisableScheduler: shouldDisableScheduler,
|
||||
_getCronExpression: getCronExpression
|
||||
};
|
||||
initialize,
|
||||
stop,
|
||||
restart,
|
||||
getStatus,
|
||||
processSummariesForFrequency,
|
||||
processRecurringTasks,
|
||||
// For testing
|
||||
_createSchedulerState: createSchedulerState,
|
||||
_shouldDisableScheduler: shouldDisableScheduler,
|
||||
_getCronExpression: getCronExpression,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,283 +4,302 @@ const TelegramPoller = require('./telegramPoller');
|
|||
|
||||
// escape markdown special characters
|
||||
const escapeMarkdown = (text) => {
|
||||
if (!text) return '';
|
||||
// Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
|
||||
return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
|
||||
if (!text) return '';
|
||||
// Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
|
||||
return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
|
||||
};
|
||||
|
||||
// get priority emoji
|
||||
const getPriorityEmoji = (priority) => {
|
||||
const emojiMap = {
|
||||
2: '🔴', // high
|
||||
1: '🟠', // medium
|
||||
0: '🟢' // low
|
||||
};
|
||||
return emojiMap[priority] || '⚪';
|
||||
const emojiMap = {
|
||||
2: '🔴', // high
|
||||
1: '🟠', // medium
|
||||
0: '🟢', // low
|
||||
};
|
||||
return emojiMap[priority] || '⚪';
|
||||
};
|
||||
|
||||
// create date range for today
|
||||
const createTodayDateRange = () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return { today, tomorrow };
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return { today, tomorrow };
|
||||
};
|
||||
|
||||
// format task for display
|
||||
const formatTaskForDisplay = (task, index, includeStatus = false) => {
|
||||
const priorityEmoji = getPriorityEmoji(task.priority);
|
||||
const statusEmoji = includeStatus ? '✅ ' : '';
|
||||
const taskName = escapeMarkdown(task.name);
|
||||
const projectInfo = task.Project ? ` \\[${escapeMarkdown(task.Project.name)}\\]` : '';
|
||||
return `${index + 1}\\. ${statusEmoji}${priorityEmoji} ${taskName}${projectInfo}\n`;
|
||||
const priorityEmoji = getPriorityEmoji(task.priority);
|
||||
const statusEmoji = includeStatus ? '✅ ' : '';
|
||||
const taskName = escapeMarkdown(task.name);
|
||||
const projectInfo = task.Project
|
||||
? ` \\[${escapeMarkdown(task.Project.name)}\\]`
|
||||
: '';
|
||||
return `${index + 1}\\. ${statusEmoji}${priorityEmoji} ${taskName}${projectInfo}\n`;
|
||||
};
|
||||
|
||||
// build task section
|
||||
const buildTaskSection = (tasks, title, includeStatus = false) => {
|
||||
if (tasks.length === 0) return '';
|
||||
|
||||
let section = `${title}\n`;
|
||||
section += tasks.map((task, index) =>
|
||||
formatTaskForDisplay(task, index, includeStatus)
|
||||
).join('');
|
||||
section += '\n';
|
||||
|
||||
return section;
|
||||
if (tasks.length === 0) return '';
|
||||
|
||||
let section = `${title}\n`;
|
||||
section += tasks
|
||||
.map((task, index) => formatTaskForDisplay(task, index, includeStatus))
|
||||
.join('');
|
||||
section += '\n';
|
||||
|
||||
return section;
|
||||
};
|
||||
|
||||
// build summary message
|
||||
const buildSummaryMessage = (taskSections) => {
|
||||
let message = "📋 *Today's Task Summary*\n\n";
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n";
|
||||
message += "✏️ *Today's Plan*\n\n";
|
||||
|
||||
message += taskSections.dueToday;
|
||||
message += taskSections.inProgress;
|
||||
message += taskSections.suggested;
|
||||
message += taskSections.completed;
|
||||
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||||
message += "🎯 *Stay focused and make it happen\\!*";
|
||||
|
||||
return message;
|
||||
let message = "📋 *Today's Task Summary*\n\n";
|
||||
message += '━━━━━━━━━━━━━━━━━━━━━━━━\n\n';
|
||||
message += "✏️ *Today's Plan*\n\n";
|
||||
|
||||
message += taskSections.dueToday;
|
||||
message += taskSections.inProgress;
|
||||
message += taskSections.suggested;
|
||||
message += taskSections.completed;
|
||||
|
||||
message += '━━━━━━━━━━━━━━━━━━━━━━━━\n';
|
||||
message += '🎯 *Stay focused and make it happen\\!*';
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
// calculate next run time
|
||||
const calculateNextRunTime = (user, fromTime = new Date()) => {
|
||||
const frequency = user.task_summary_frequency;
|
||||
const from = new Date(fromTime);
|
||||
const frequency = user.task_summary_frequency;
|
||||
const from = new Date(fromTime);
|
||||
|
||||
const calculations = {
|
||||
daily: () => {
|
||||
const nextDay = new Date(from);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
nextDay.setHours(7, 0, 0, 0);
|
||||
return nextDay;
|
||||
},
|
||||
|
||||
weekdays: () => {
|
||||
const currentDay = from.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||
let daysToAdd = 1;
|
||||
if (currentDay === 5) { // Friday
|
||||
daysToAdd = 3; // Skip to Monday
|
||||
} else if (currentDay === 6) { // Saturday
|
||||
daysToAdd = 2; // Skip to Monday
|
||||
}
|
||||
const nextWeekday = new Date(from);
|
||||
nextWeekday.setDate(nextWeekday.getDate() + daysToAdd);
|
||||
nextWeekday.setHours(7, 0, 0, 0);
|
||||
return nextWeekday;
|
||||
},
|
||||
|
||||
weekly: () => {
|
||||
const nextWeek = new Date(from);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
nextWeek.setHours(7, 0, 0, 0);
|
||||
return nextWeek;
|
||||
},
|
||||
|
||||
'1h': () => {
|
||||
const nextHour = new Date(from);
|
||||
nextHour.setHours(nextHour.getHours() + 1);
|
||||
return nextHour;
|
||||
},
|
||||
|
||||
'2h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 2);
|
||||
return next;
|
||||
},
|
||||
|
||||
'4h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 4);
|
||||
return next;
|
||||
},
|
||||
|
||||
'8h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 8);
|
||||
return next;
|
||||
},
|
||||
|
||||
'12h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 12);
|
||||
return next;
|
||||
}
|
||||
};
|
||||
const calculations = {
|
||||
daily: () => {
|
||||
const nextDay = new Date(from);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
nextDay.setHours(7, 0, 0, 0);
|
||||
return nextDay;
|
||||
},
|
||||
|
||||
const calculator = calculations[frequency];
|
||||
return calculator ? calculator() : calculations.daily();
|
||||
weekdays: () => {
|
||||
const currentDay = from.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||
let daysToAdd = 1;
|
||||
if (currentDay === 5) {
|
||||
// Friday
|
||||
daysToAdd = 3; // Skip to Monday
|
||||
} else if (currentDay === 6) {
|
||||
// Saturday
|
||||
daysToAdd = 2; // Skip to Monday
|
||||
}
|
||||
const nextWeekday = new Date(from);
|
||||
nextWeekday.setDate(nextWeekday.getDate() + daysToAdd);
|
||||
nextWeekday.setHours(7, 0, 0, 0);
|
||||
return nextWeekday;
|
||||
},
|
||||
|
||||
weekly: () => {
|
||||
const nextWeek = new Date(from);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
nextWeek.setHours(7, 0, 0, 0);
|
||||
return nextWeek;
|
||||
},
|
||||
|
||||
'1h': () => {
|
||||
const nextHour = new Date(from);
|
||||
nextHour.setHours(nextHour.getHours() + 1);
|
||||
return nextHour;
|
||||
},
|
||||
|
||||
'2h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 2);
|
||||
return next;
|
||||
},
|
||||
|
||||
'4h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 4);
|
||||
return next;
|
||||
},
|
||||
|
||||
'8h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 8);
|
||||
return next;
|
||||
},
|
||||
|
||||
'12h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 12);
|
||||
return next;
|
||||
},
|
||||
};
|
||||
|
||||
const calculator = calculations[frequency];
|
||||
return calculator ? calculator() : calculations.daily();
|
||||
};
|
||||
|
||||
// Side effect function to fetch user by ID
|
||||
const fetchUser = async (userId) =>
|
||||
await User.findByPk(userId);
|
||||
const fetchUser = async (userId) => await User.findByPk(userId);
|
||||
|
||||
// Side effect function to fetch due today tasks
|
||||
const fetchDueTodayTasks = async (userId, today, tomorrow) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
due_date: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow
|
||||
},
|
||||
status: { [Op.ne]: 2 } // not done
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
const fetchDueTodayTasks = async (userId, today, tomorrow) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
due_date: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow,
|
||||
},
|
||||
status: { [Op.ne]: 2 }, // not done
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
// Side effect function to fetch in progress tasks
|
||||
const fetchInProgressTasks = async (userId) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 1 // in_progress
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
const fetchInProgressTasks = async (userId) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 1, // in_progress
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
// Side effect function to fetch completed today tasks
|
||||
const fetchCompletedTodayTasks = async (userId, today, tomorrow) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 2, // done
|
||||
updated_at: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow
|
||||
}
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
const fetchCompletedTodayTasks = async (userId, today, tomorrow) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 2, // done
|
||||
updated_at: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow,
|
||||
},
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
// Side effect function to fetch suggested tasks
|
||||
const fetchSuggestedTasks = async (userId, excludedIds) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: { [Op.ne]: 2 }, // not done
|
||||
id: { [Op.notIn]: excludedIds }
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['priority', 'DESC'], ['name', 'ASC']],
|
||||
limit: 5
|
||||
});
|
||||
const fetchSuggestedTasks = async (userId, excludedIds) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: { [Op.ne]: 2 }, // not done
|
||||
id: { [Op.notIn]: excludedIds },
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [
|
||||
['priority', 'DESC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Side effect function to send telegram message
|
||||
const sendTelegramMessage = async (token, chatId, message) => {
|
||||
const poller = TelegramPoller;
|
||||
return await poller.sendTelegramMessage(token, chatId, message);
|
||||
const poller = TelegramPoller;
|
||||
return await poller.sendTelegramMessage(token, chatId, message);
|
||||
};
|
||||
|
||||
// Side effect function to update user tracking fields
|
||||
const updateUserTracking = async (user, lastRun, nextRun) =>
|
||||
await user.update({
|
||||
task_summary_last_run: lastRun,
|
||||
task_summary_next_run: nextRun
|
||||
});
|
||||
const updateUserTracking = async (user, lastRun, nextRun) =>
|
||||
await user.update({
|
||||
task_summary_last_run: lastRun,
|
||||
task_summary_next_run: nextRun,
|
||||
});
|
||||
|
||||
// Function to generate summary for user (contains side effects)
|
||||
const generateSummaryForUser = async (userId) => {
|
||||
try {
|
||||
const user = await fetchUser(userId);
|
||||
if (!user) return null;
|
||||
try {
|
||||
const user = await fetchUser(userId);
|
||||
if (!user) return null;
|
||||
|
||||
const { today, tomorrow } = createTodayDateRange();
|
||||
const { today, tomorrow } = createTodayDateRange();
|
||||
|
||||
// Fetch all task data in parallel
|
||||
const [dueToday, inProgress, completedToday] = await Promise.all([
|
||||
fetchDueTodayTasks(userId, today, tomorrow),
|
||||
fetchInProgressTasks(userId),
|
||||
fetchCompletedTodayTasks(userId, today, tomorrow)
|
||||
]);
|
||||
// Fetch all task data in parallel
|
||||
const [dueToday, inProgress, completedToday] = await Promise.all([
|
||||
fetchDueTodayTasks(userId, today, tomorrow),
|
||||
fetchInProgressTasks(userId),
|
||||
fetchCompletedTodayTasks(userId, today, tomorrow),
|
||||
]);
|
||||
|
||||
// Get suggested tasks (excluding already fetched ones)
|
||||
const excludedIds = [...dueToday.map(t => t.id), ...inProgress.map(t => t.id)];
|
||||
const suggestedTasks = await fetchSuggestedTasks(userId, excludedIds);
|
||||
// Get suggested tasks (excluding already fetched ones)
|
||||
const excludedIds = [
|
||||
...dueToday.map((t) => t.id),
|
||||
...inProgress.map((t) => t.id),
|
||||
];
|
||||
const suggestedTasks = await fetchSuggestedTasks(userId, excludedIds);
|
||||
|
||||
// Build task sections
|
||||
const taskSections = {
|
||||
dueToday: buildTaskSection(dueToday, "🚀 *Tasks Due Today:*"),
|
||||
inProgress: buildTaskSection(inProgress, "⚙️ *In Progress Tasks:*"),
|
||||
suggested: buildTaskSection(suggestedTasks, "💡 *Suggested Tasks:*"),
|
||||
completed: buildTaskSection(completedToday, "✅ *Completed Today:*", true)
|
||||
};
|
||||
// Build task sections
|
||||
const taskSections = {
|
||||
dueToday: buildTaskSection(dueToday, '🚀 *Tasks Due Today:*'),
|
||||
inProgress: buildTaskSection(inProgress, '⚙️ *In Progress Tasks:*'),
|
||||
suggested: buildTaskSection(
|
||||
suggestedTasks,
|
||||
'💡 *Suggested Tasks:*'
|
||||
),
|
||||
completed: buildTaskSection(
|
||||
completedToday,
|
||||
'✅ *Completed Today:*',
|
||||
true
|
||||
),
|
||||
};
|
||||
|
||||
return buildSummaryMessage(taskSections);
|
||||
} catch (error) {
|
||||
console.error('Error generating task summary:', error);
|
||||
return null;
|
||||
}
|
||||
return buildSummaryMessage(taskSections);
|
||||
} catch (error) {
|
||||
console.error('Error generating task summary:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to send summary to user (contains side effects)
|
||||
const sendSummaryToUser = async (userId) => {
|
||||
try {
|
||||
const user = await fetchUser(userId);
|
||||
if (!user || !user.telegram_bot_token || !user.telegram_chat_id) {
|
||||
return false;
|
||||
try {
|
||||
const user = await fetchUser(userId);
|
||||
if (!user || !user.telegram_bot_token || !user.telegram_chat_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = await generateSummaryForUser(userId);
|
||||
if (!summary) return false;
|
||||
|
||||
// Send the message via Telegram
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
user.telegram_chat_id,
|
||||
summary
|
||||
);
|
||||
|
||||
// Update tracking fields
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRunTime(user, now);
|
||||
await updateUserTracking(user, now, nextRun);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error sending task summary to user ${userId}:`,
|
||||
error.message
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = await generateSummaryForUser(userId);
|
||||
if (!summary) return false;
|
||||
|
||||
// Send the message via Telegram
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
user.telegram_chat_id,
|
||||
summary
|
||||
);
|
||||
|
||||
// Update tracking fields
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRunTime(user, now);
|
||||
await updateUserTracking(user, now, nextRun);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error sending task summary to user ${userId}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
generateSummaryForUser,
|
||||
sendSummaryToUser,
|
||||
calculateNextRunTime,
|
||||
// For testing
|
||||
_escapeMarkdown: escapeMarkdown,
|
||||
_getPriorityEmoji: getPriorityEmoji,
|
||||
_createTodayDateRange: createTodayDateRange,
|
||||
_formatTaskForDisplay: formatTaskForDisplay,
|
||||
_buildTaskSection: buildTaskSection,
|
||||
_buildSummaryMessage: buildSummaryMessage
|
||||
};
|
||||
generateSummaryForUser,
|
||||
sendSummaryToUser,
|
||||
calculateNextRunTime,
|
||||
// For testing
|
||||
_escapeMarkdown: escapeMarkdown,
|
||||
_getPriorityEmoji: getPriorityEmoji,
|
||||
_createTodayDateRange: createTodayDateRange,
|
||||
_formatTaskForDisplay: formatTaskForDisplay,
|
||||
_buildTaskSection: buildTaskSection,
|
||||
_buildSummaryMessage: buildSummaryMessage,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,29 +2,32 @@ const telegramPoller = require('./telegramPoller');
|
|||
const { User } = require('../models');
|
||||
|
||||
async function initializeTelegramPolling() {
|
||||
if (process.env.NODE_ENV === 'test' || process.env.DISABLE_TELEGRAM === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find users with configured Telegram tokens
|
||||
const usersWithTelegram = await User.findAll({
|
||||
where: {
|
||||
telegram_bot_token: {
|
||||
[require('sequelize').Op.ne]: null
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (usersWithTelegram.length > 0) {
|
||||
// Add each user to the polling list
|
||||
for (const user of usersWithTelegram) {
|
||||
await telegramPoller.addUser(user);
|
||||
}
|
||||
if (
|
||||
process.env.NODE_ENV === 'test' ||
|
||||
process.env.DISABLE_TELEGRAM === 'true'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find users with configured Telegram tokens
|
||||
const usersWithTelegram = await User.findAll({
|
||||
where: {
|
||||
telegram_bot_token: {
|
||||
[require('sequelize').Op.ne]: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (usersWithTelegram.length > 0) {
|
||||
// Add each user to the polling list
|
||||
for (const user of usersWithTelegram) {
|
||||
await telegramPoller.addUser(user);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Telegram polling will be initialized later when the database is available
|
||||
}
|
||||
} catch (error) {
|
||||
// Telegram polling will be initialized later when the database is available
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initializeTelegramPolling };
|
||||
module.exports = { initializeTelegramPolling };
|
||||
|
|
|
|||
|
|
@ -3,400 +3,422 @@ const { User, InboxItem } = require('../models');
|
|||
|
||||
// Create poller state
|
||||
const createPollerState = () => ({
|
||||
running: false,
|
||||
interval: null,
|
||||
pollInterval: 5000, // 5 seconds
|
||||
usersToPool: [],
|
||||
userStatus: {},
|
||||
processedUpdates: new Set() // Track processed update IDs to prevent duplicates
|
||||
running: false,
|
||||
interval: null,
|
||||
pollInterval: 5000, // 5 seconds
|
||||
usersToPool: [],
|
||||
userStatus: {},
|
||||
processedUpdates: new Set(), // Track processed update IDs to prevent duplicates
|
||||
});
|
||||
|
||||
// Global mutable state (managed functionally)
|
||||
let pollerState = createPollerState();
|
||||
|
||||
// Check if user exists in list
|
||||
const userExistsInList = (users, userId) =>
|
||||
users.some(u => u.id === userId);
|
||||
const userExistsInList = (users, userId) => users.some((u) => u.id === userId);
|
||||
|
||||
// Add user to list
|
||||
const addUserToList = (users, user) => {
|
||||
if (userExistsInList(users, user.id)) {
|
||||
return users;
|
||||
}
|
||||
return [...users, user];
|
||||
if (userExistsInList(users, user.id)) {
|
||||
return users;
|
||||
}
|
||||
return [...users, user];
|
||||
};
|
||||
|
||||
// Remove user from list
|
||||
const removeUserFromList = (users, userId) =>
|
||||
users.filter(u => u.id !== userId);
|
||||
const removeUserFromList = (users, userId) =>
|
||||
users.filter((u) => u.id !== userId);
|
||||
|
||||
// Remove user status
|
||||
const removeUserStatus = (userStatus, userId) => {
|
||||
const { [userId]: removed, ...rest } = userStatus;
|
||||
return rest;
|
||||
const { [userId]: removed, ...rest } = userStatus;
|
||||
return rest;
|
||||
};
|
||||
|
||||
// Update user status
|
||||
const updateUserStatus = (userStatus, userId, updates) => ({
|
||||
...userStatus,
|
||||
[userId]: {
|
||||
...userStatus[userId],
|
||||
...updates
|
||||
}
|
||||
...userStatus,
|
||||
[userId]: {
|
||||
...userStatus[userId],
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
|
||||
// Get highest update ID from updates
|
||||
const getHighestUpdateId = (updates) => {
|
||||
if (!updates.length) return 0;
|
||||
return Math.max(...updates.map(u => u.update_id));
|
||||
if (!updates.length) return 0;
|
||||
return Math.max(...updates.map((u) => u.update_id));
|
||||
};
|
||||
|
||||
// Create message parameters
|
||||
const createMessageParams = (chatId, text, replyToMessageId = null) => {
|
||||
const params = { chat_id: chatId, text: text };
|
||||
if (replyToMessageId) {
|
||||
params.reply_to_message_id = replyToMessageId;
|
||||
}
|
||||
return params;
|
||||
const params = { chat_id: chatId, text: text };
|
||||
if (replyToMessageId) {
|
||||
params.reply_to_message_id = replyToMessageId;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
// Create Telegram API URL
|
||||
const createTelegramUrl = (token, endpoint, params = {}) => {
|
||||
const baseUrl = `https://api.telegram.org/bot${token}/${endpoint}`;
|
||||
if (Object.keys(params).length === 0) return baseUrl;
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
return `${baseUrl}?${searchParams}`;
|
||||
const baseUrl = `https://api.telegram.org/bot${token}/${endpoint}`;
|
||||
if (Object.keys(params).length === 0) return baseUrl;
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
return `${baseUrl}?${searchParams}`;
|
||||
};
|
||||
|
||||
// Side effect function to make HTTP GET request
|
||||
const makeHttpGetRequest = (url, timeout = 5000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { timeout }, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
reject(error);
|
||||
}).on('timeout', () => {
|
||||
reject(new Error('Request timeout'));
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { timeout }, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', (error) => {
|
||||
reject(error);
|
||||
})
|
||||
.on('timeout', () => {
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Side effect function to make HTTP POST request
|
||||
const makeHttpPostRequest = (url, postData, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
// Side effect function to get Telegram updates
|
||||
const getTelegramUpdates = async (token, offset) => {
|
||||
try {
|
||||
const url = createTelegramUrl(token, 'getUpdates', {
|
||||
offset: offset.toString(),
|
||||
timeout: '1'
|
||||
});
|
||||
|
||||
const response = await makeHttpGetRequest(url, 5000);
|
||||
|
||||
if (response.ok && Array.isArray(response.result)) {
|
||||
return response.result;
|
||||
} else {
|
||||
return [];
|
||||
try {
|
||||
const url = createTelegramUrl(token, 'getUpdates', {
|
||||
offset: offset.toString(),
|
||||
timeout: '1',
|
||||
});
|
||||
|
||||
const response = await makeHttpGetRequest(url, 5000);
|
||||
|
||||
if (response.ok && Array.isArray(response.result)) {
|
||||
return response.result;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Side effect function to send Telegram message
|
||||
const sendTelegramMessage = async (token, chatId, text, replyToMessageId = null) => {
|
||||
try {
|
||||
const messageParams = createMessageParams(chatId, text, replyToMessageId);
|
||||
const postData = JSON.stringify(messageParams);
|
||||
const url = createTelegramUrl(token, 'sendMessage');
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
};
|
||||
const sendTelegramMessage = async (
|
||||
token,
|
||||
chatId,
|
||||
text,
|
||||
replyToMessageId = null
|
||||
) => {
|
||||
try {
|
||||
const messageParams = createMessageParams(
|
||||
chatId,
|
||||
text,
|
||||
replyToMessageId
|
||||
);
|
||||
const postData = JSON.stringify(messageParams);
|
||||
const url = createTelegramUrl(token, 'sendMessage');
|
||||
|
||||
return await makeHttpPostRequest(url, postData, options);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
},
|
||||
};
|
||||
|
||||
return await makeHttpPostRequest(url, postData, options);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Side effect function to update user chat ID
|
||||
const updateUserChatId = async (userId, chatId) => {
|
||||
await User.update(
|
||||
{ telegram_chat_id: chatId },
|
||||
{ where: { id: userId } }
|
||||
);
|
||||
await User.update({ telegram_chat_id: chatId }, { where: { id: userId } });
|
||||
};
|
||||
|
||||
// Side effect function to create inbox item
|
||||
const createInboxItem = async (content, userId, messageId) => {
|
||||
// Check if a similar item was created recently (within last 30 seconds)
|
||||
// to prevent duplicates from network issues or multiple processing
|
||||
const recentCutoff = new Date(Date.now() - 30000); // 30 seconds ago
|
||||
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: content,
|
||||
user_id: userId,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: recentCutoff
|
||||
}
|
||||
// Check if a similar item was created recently (within last 30 seconds)
|
||||
// to prevent duplicates from network issues or multiple processing
|
||||
const recentCutoff = new Date(Date.now() - 30000); // 30 seconds ago
|
||||
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: content,
|
||||
user_id: userId,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: recentCutoff,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingItem) {
|
||||
console.log(
|
||||
`Duplicate inbox item detected for user ${userId}, content: "${content}". Skipping creation.`
|
||||
);
|
||||
return existingItem;
|
||||
}
|
||||
});
|
||||
|
||||
if (existingItem) {
|
||||
console.log(`Duplicate inbox item detected for user ${userId}, content: "${content}". Skipping creation.`);
|
||||
return existingItem;
|
||||
}
|
||||
|
||||
return await InboxItem.create({
|
||||
content: content,
|
||||
source: 'telegram',
|
||||
user_id: userId,
|
||||
metadata: { telegram_message_id: messageId } // Store message ID for reference
|
||||
});
|
||||
|
||||
return await InboxItem.create({
|
||||
content: content,
|
||||
source: 'telegram',
|
||||
user_id: userId,
|
||||
metadata: { telegram_message_id: messageId }, // Store message ID for reference
|
||||
});
|
||||
};
|
||||
|
||||
// Function to process a single message (contains side effects)
|
||||
const processMessage = async (user, update) => {
|
||||
const message = update.message;
|
||||
const text = message.text;
|
||||
const chatId = message.chat.id.toString();
|
||||
const messageId = message.message_id;
|
||||
const message = update.message;
|
||||
const text = message.text;
|
||||
const chatId = message.chat.id.toString();
|
||||
const messageId = message.message_id;
|
||||
|
||||
// Update chat ID if needed
|
||||
if (!user.telegram_chat_id) {
|
||||
await updateUserChatId(user.id, chatId);
|
||||
user.telegram_chat_id = chatId; // Update local object
|
||||
}
|
||||
// Update chat ID if needed
|
||||
if (!user.telegram_chat_id) {
|
||||
await updateUserChatId(user.id, chatId);
|
||||
user.telegram_chat_id = chatId; // Update local object
|
||||
}
|
||||
|
||||
try {
|
||||
// Create inbox item (with duplicate check)
|
||||
const inboxItem = await createInboxItem(text, user.id, messageId);
|
||||
try {
|
||||
// Create inbox item (with duplicate check)
|
||||
const inboxItem = await createInboxItem(text, user.id, messageId);
|
||||
|
||||
// Send confirmation
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`✅ Added to Tududi inbox: "${text}"`,
|
||||
messageId
|
||||
);
|
||||
|
||||
console.log(`Successfully processed message ${messageId} for user ${user.id}: "${text}"`);
|
||||
} catch (error) {
|
||||
// Send error message
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`❌ Failed to add to inbox: ${error.message}`,
|
||||
messageId
|
||||
);
|
||||
}
|
||||
// Send confirmation
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`✅ Added to Tududi inbox: "${text}"`,
|
||||
messageId
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Successfully processed message ${messageId} for user ${user.id}: "${text}"`
|
||||
);
|
||||
} catch (error) {
|
||||
// Send error message
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`❌ Failed to add to inbox: ${error.message}`,
|
||||
messageId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to process updates (contains side effects)
|
||||
const processUpdates = async (user, updates) => {
|
||||
if (!updates.length) return;
|
||||
if (!updates.length) return;
|
||||
|
||||
// Filter out already processed updates
|
||||
const newUpdates = updates.filter(update => {
|
||||
const updateKey = `${user.id}-${update.update_id}`;
|
||||
return !pollerState.processedUpdates.has(updateKey);
|
||||
});
|
||||
// Filter out already processed updates
|
||||
const newUpdates = updates.filter((update) => {
|
||||
const updateKey = `${user.id}-${update.update_id}`;
|
||||
return !pollerState.processedUpdates.has(updateKey);
|
||||
});
|
||||
|
||||
if (!newUpdates.length) return;
|
||||
if (!newUpdates.length) return;
|
||||
|
||||
// Get highest update ID from new updates
|
||||
const highestUpdateId = getHighestUpdateId(newUpdates);
|
||||
|
||||
// Update user status
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
userStatus: updateUserStatus(pollerState.userStatus, user.id, {
|
||||
lastUpdateId: highestUpdateId
|
||||
})
|
||||
};
|
||||
// Get highest update ID from new updates
|
||||
const highestUpdateId = getHighestUpdateId(newUpdates);
|
||||
|
||||
// Process each new update
|
||||
for (const update of newUpdates) {
|
||||
try {
|
||||
const updateKey = `${user.id}-${update.update_id}`;
|
||||
|
||||
if (update.message && update.message.text) {
|
||||
await processMessage(user, update);
|
||||
|
||||
// Mark update as processed
|
||||
pollerState.processedUpdates.add(updateKey);
|
||||
|
||||
// Clean up old processed updates (keep only last 1000 to prevent memory leak)
|
||||
if (pollerState.processedUpdates.size > 1000) {
|
||||
const oldestEntries = Array.from(pollerState.processedUpdates).slice(0, 100);
|
||||
oldestEntries.forEach(entry => pollerState.processedUpdates.delete(entry));
|
||||
// Update user status
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
userStatus: updateUserStatus(pollerState.userStatus, user.id, {
|
||||
lastUpdateId: highestUpdateId,
|
||||
}),
|
||||
};
|
||||
|
||||
// Process each new update
|
||||
for (const update of newUpdates) {
|
||||
try {
|
||||
const updateKey = `${user.id}-${update.update_id}`;
|
||||
|
||||
if (update.message && update.message.text) {
|
||||
await processMessage(user, update);
|
||||
|
||||
// Mark update as processed
|
||||
pollerState.processedUpdates.add(updateKey);
|
||||
|
||||
// Clean up old processed updates (keep only last 1000 to prevent memory leak)
|
||||
if (pollerState.processedUpdates.size > 1000) {
|
||||
const oldestEntries = Array.from(
|
||||
pollerState.processedUpdates
|
||||
).slice(0, 100);
|
||||
oldestEntries.forEach((entry) =>
|
||||
pollerState.processedUpdates.delete(entry)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error processing update ${update.update_id} for user ${user.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing update ${update.update_id} for user ${user.id}:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to poll updates for all users (contains side effects)
|
||||
const pollUpdates = async () => {
|
||||
for (const user of pollerState.usersToPool) {
|
||||
const token = user.telegram_bot_token;
|
||||
if (!token) continue;
|
||||
for (const user of pollerState.usersToPool) {
|
||||
const token = user.telegram_bot_token;
|
||||
if (!token) continue;
|
||||
|
||||
try {
|
||||
const lastUpdateId = pollerState.userStatus[user.id]?.lastUpdateId || 0;
|
||||
const updates = await getTelegramUpdates(token, lastUpdateId + 1);
|
||||
|
||||
if (updates && updates.length > 0) {
|
||||
console.log(`Processing ${updates.length} updates for user ${user.id}, starting from update ID ${lastUpdateId + 1}`);
|
||||
await processUpdates(user, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting updates for user ${user.id}:`, error);
|
||||
try {
|
||||
const lastUpdateId =
|
||||
pollerState.userStatus[user.id]?.lastUpdateId || 0;
|
||||
const updates = await getTelegramUpdates(token, lastUpdateId + 1);
|
||||
|
||||
if (updates && updates.length > 0) {
|
||||
console.log(
|
||||
`Processing ${updates.length} updates for user ${user.id}, starting from update ID ${lastUpdateId + 1}`
|
||||
);
|
||||
await processUpdates(user, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting updates for user ${user.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to start polling (contains side effects)
|
||||
const startPolling = () => {
|
||||
if (pollerState.running) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await pollUpdates();
|
||||
} catch (error) {
|
||||
// Error polling Telegram
|
||||
}
|
||||
}, pollerState.pollInterval);
|
||||
if (pollerState.running) return;
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
running: true,
|
||||
interval
|
||||
};
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await pollUpdates();
|
||||
} catch (error) {
|
||||
// Error polling Telegram
|
||||
}
|
||||
}, pollerState.pollInterval);
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
running: true,
|
||||
interval,
|
||||
};
|
||||
};
|
||||
|
||||
// Function to stop polling (contains side effects)
|
||||
const stopPolling = () => {
|
||||
if (!pollerState.running) return;
|
||||
|
||||
if (pollerState.interval) {
|
||||
clearInterval(pollerState.interval);
|
||||
}
|
||||
if (!pollerState.running) return;
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
running: false,
|
||||
interval: null
|
||||
};
|
||||
if (pollerState.interval) {
|
||||
clearInterval(pollerState.interval);
|
||||
}
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
running: false,
|
||||
interval: null,
|
||||
};
|
||||
};
|
||||
|
||||
// Function to add user (contains side effects)
|
||||
const addUser = async (user) => {
|
||||
if (!user || !user.telegram_bot_token) {
|
||||
return false;
|
||||
}
|
||||
if (!user || !user.telegram_bot_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add user to list
|
||||
const newUsersList = addUserToList(pollerState.usersToPool, user);
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
usersToPool: newUsersList
|
||||
};
|
||||
// Add user to list
|
||||
const newUsersList = addUserToList(pollerState.usersToPool, user);
|
||||
|
||||
// Start polling if not already running and we have users
|
||||
if (pollerState.usersToPool.length > 0 && !pollerState.running) {
|
||||
startPolling();
|
||||
}
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
usersToPool: newUsersList,
|
||||
};
|
||||
|
||||
return true;
|
||||
// Start polling if not already running and we have users
|
||||
if (pollerState.usersToPool.length > 0 && !pollerState.running) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Function to remove user (contains side effects)
|
||||
const removeUser = (userId) => {
|
||||
// Remove user from list and status
|
||||
const newUsersList = removeUserFromList(pollerState.usersToPool, userId);
|
||||
const newUserStatus = removeUserStatus(pollerState.userStatus, userId);
|
||||
// Remove user from list and status
|
||||
const newUsersList = removeUserFromList(pollerState.usersToPool, userId);
|
||||
const newUserStatus = removeUserStatus(pollerState.userStatus, userId);
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
usersToPool: newUsersList,
|
||||
userStatus: newUserStatus
|
||||
};
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
usersToPool: newUsersList,
|
||||
userStatus: newUserStatus,
|
||||
};
|
||||
|
||||
// Stop polling if no users left
|
||||
if (pollerState.usersToPool.length === 0 && pollerState.running) {
|
||||
stopPolling();
|
||||
}
|
||||
// Stop polling if no users left
|
||||
if (pollerState.usersToPool.length === 0 && pollerState.running) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
return true;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get poller status
|
||||
const getStatus = () => ({
|
||||
running: pollerState.running,
|
||||
usersCount: pollerState.usersToPool.length,
|
||||
pollInterval: pollerState.pollInterval,
|
||||
userStatus: pollerState.userStatus
|
||||
running: pollerState.running,
|
||||
usersCount: pollerState.usersToPool.length,
|
||||
pollInterval: pollerState.pollInterval,
|
||||
userStatus: pollerState.userStatus,
|
||||
});
|
||||
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
addUser,
|
||||
removeUser,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
getStatus,
|
||||
sendTelegramMessage,
|
||||
// For testing
|
||||
_createPollerState: createPollerState,
|
||||
_userExistsInList: userExistsInList,
|
||||
_addUserToList: addUserToList,
|
||||
_removeUserFromList: removeUserFromList,
|
||||
_getHighestUpdateId: getHighestUpdateId,
|
||||
_createMessageParams: createMessageParams,
|
||||
_createTelegramUrl: createTelegramUrl
|
||||
};
|
||||
addUser,
|
||||
removeUser,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
getStatus,
|
||||
sendTelegramMessage,
|
||||
// For testing
|
||||
_createPollerState: createPollerState,
|
||||
_userExistsInList: userExistsInList,
|
||||
_addUserToList: addUserToList,
|
||||
_removeUserFromList: removeUserFromList,
|
||||
_getHighestUpdateId: getHighestUpdateId,
|
||||
_createMessageParams: createMessageParams,
|
||||
_createTelegramUrl: createTelegramUrl,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,26 +18,31 @@ tests/
|
|||
## Running Tests
|
||||
|
||||
### All Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Unit Tests Only
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### Integration Tests Only
|
||||
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### Watch Mode (for development)
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### Coverage Report
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
|
@ -45,6 +50,7 @@ npm run test:coverage
|
|||
## Test Environment
|
||||
|
||||
Tests run in a separate test environment with:
|
||||
|
||||
- In-memory SQLite database (isolated from development data)
|
||||
- Test-specific configuration from `.env.test`
|
||||
- Automatic database cleanup between tests
|
||||
|
|
@ -52,17 +58,20 @@ Tests run in a separate test environment with:
|
|||
## Writing Tests
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Test individual functions, models, or middleware in isolation
|
||||
- Mock external dependencies
|
||||
- Focus on business logic and edge cases
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Test complete API endpoints
|
||||
- Use authenticated requests where needed
|
||||
- Test real database interactions
|
||||
- Verify response formats and status codes
|
||||
|
||||
### Test Utilities
|
||||
|
||||
- `tests/helpers/testUtils.js` provides utilities for creating test data
|
||||
- `tests/helpers/setup.js` handles database setup and cleanup
|
||||
- Use `createTestUser()` for creating authenticated test users
|
||||
|
|
@ -79,4 +88,4 @@ Tests run in a separate test environment with:
|
|||
|
||||
- **Jest**: Test framework
|
||||
- **Supertest**: HTTP testing library for integration tests
|
||||
- **cross-env**: Cross-platform environment variable setting
|
||||
- **cross-env**: Cross-platform environment variable setting
|
||||
|
|
|
|||
|
|
@ -6,37 +6,43 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure test database is clean and created
|
||||
await sequelize.sync({ force: true });
|
||||
// Ensure test database is clean and created
|
||||
await sequelize.sync({ force: true });
|
||||
}, 30000);
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean all tables except Sessions to avoid conflicts
|
||||
try {
|
||||
const models = Object.values(sequelize.models);
|
||||
const nonSessionModels = models.filter(model => model.name !== 'Session');
|
||||
await Promise.all(nonSessionModels.map(model => model.destroy({ truncate: true, cascade: true })));
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
// Clean all tables except Sessions to avoid conflicts
|
||||
try {
|
||||
const models = Object.values(sequelize.models);
|
||||
const nonSessionModels = models.filter(
|
||||
(model) => model.name !== 'Session'
|
||||
);
|
||||
await Promise.all(
|
||||
nonSessionModels.map((model) =>
|
||||
model.destroy({ truncate: true, cascade: true })
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up sessions after each test
|
||||
try {
|
||||
const Session = sequelize.models.Session;
|
||||
if (Session) {
|
||||
await Session.destroy({ truncate: true });
|
||||
// Clean up sessions after each test
|
||||
try {
|
||||
const Session = sequelize.models.Session;
|
||||
if (Session) {
|
||||
await Session.destroy({ truncate: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors during session cleanup
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors during session cleanup
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await sequelize.close();
|
||||
} catch (error) {
|
||||
// Database may already be closed
|
||||
}
|
||||
}, 30000);
|
||||
try {
|
||||
await sequelize.close();
|
||||
} catch (error) {
|
||||
// Database may already be closed
|
||||
}
|
||||
}, 30000);
|
||||
|
|
|
|||
|
|
@ -2,27 +2,25 @@ const bcrypt = require('bcrypt');
|
|||
const { User } = require('../../models');
|
||||
|
||||
const createTestUser = async (userData = {}) => {
|
||||
const defaultUser = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123', // Use password field to trigger model hook
|
||||
...userData
|
||||
};
|
||||
|
||||
return await User.create(defaultUser);
|
||||
const defaultUser = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123', // Use password field to trigger model hook
|
||||
...userData,
|
||||
};
|
||||
|
||||
return await User.create(defaultUser);
|
||||
};
|
||||
|
||||
const authenticateUser = async (request, user) => {
|
||||
const response = await request
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: user.email,
|
||||
password: 'password123'
|
||||
const response = await request.post('/api/login').send({
|
||||
email: user.email,
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
return response.headers['set-cookie'];
|
||||
|
||||
return response.headers['set-cookie'];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createTestUser,
|
||||
authenticateUser
|
||||
};
|
||||
createTestUser,
|
||||
authenticateUser,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,277 +4,275 @@ const { Area, User } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Areas Routes', () => {
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/areas', () => {
|
||||
it('should create a new area', async () => {
|
||||
const areaData = {
|
||||
name: 'Work',
|
||||
description: 'Work related projects'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/areas')
|
||||
.send(areaData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(areaData.name);
|
||||
expect(response.body.description).toBe(areaData.description);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const areaData = {
|
||||
name: 'Work'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/areas')
|
||||
.send(areaData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require area name', async () => {
|
||||
const areaData = {
|
||||
description: 'Area without name'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/areas')
|
||||
.send(areaData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Area name is required.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/areas', () => {
|
||||
let area1, area2;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
area1 = await Area.create({
|
||||
name: 'Work',
|
||||
description: 'Work projects',
|
||||
user_id: user.id
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
area2 = await Area.create({
|
||||
name: 'Personal',
|
||||
description: 'Personal projects',
|
||||
user_id: user.id
|
||||
});
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user areas', async () => {
|
||||
const response = await agent.get('/api/areas');
|
||||
describe('POST /api/areas', () => {
|
||||
it('should create a new area', async () => {
|
||||
const areaData = {
|
||||
name: 'Work',
|
||||
description: 'Work related projects',
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
expect(response.body.map(a => a.id)).toContain(area1.id);
|
||||
expect(response.body.map(a => a.id)).toContain(area2.id);
|
||||
const response = await agent.post('/api/areas').send(areaData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(areaData.name);
|
||||
expect(response.body.description).toBe(areaData.description);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const areaData = {
|
||||
name: 'Work',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/areas')
|
||||
.send(areaData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require area name', async () => {
|
||||
const areaData = {
|
||||
description: 'Area without name',
|
||||
};
|
||||
|
||||
const response = await agent.post('/api/areas').send(areaData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Area name is required.');
|
||||
});
|
||||
});
|
||||
|
||||
it('should order areas by name', async () => {
|
||||
const response = await agent.get('/api/areas');
|
||||
describe('GET /api/areas', () => {
|
||||
let area1, area2;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body[0].name).toBe('Personal'); // P comes before W
|
||||
expect(response.body[1].name).toBe('Work');
|
||||
beforeEach(async () => {
|
||||
area1 = await Area.create({
|
||||
name: 'Work',
|
||||
description: 'Work projects',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
area2 = await Area.create({
|
||||
name: 'Personal',
|
||||
description: 'Personal projects',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user areas', async () => {
|
||||
const response = await agent.get('/api/areas');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
expect(response.body.map((a) => a.id)).toContain(area1.id);
|
||||
expect(response.body.map((a) => a.id)).toContain(area2.id);
|
||||
});
|
||||
|
||||
it('should order areas by name', async () => {
|
||||
const response = await agent.get('/api/areas');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body[0].name).toBe('Personal'); // P comes before W
|
||||
expect(response.body[1].name).toBe('Work');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/areas');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/areas');
|
||||
describe('GET /api/areas/:id', () => {
|
||||
let area;
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
beforeEach(async () => {
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
description: 'Work projects',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/areas/:id', () => {
|
||||
let area;
|
||||
it('should get area by id', async () => {
|
||||
const response = await agent.get(`/api/areas/${area.id}`);
|
||||
|
||||
beforeEach(async () => {
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
description: 'Work projects',
|
||||
user_id: user.id
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(area.id);
|
||||
expect(response.body.name).toBe(area.name);
|
||||
expect(response.body.description).toBe(area.description);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent.get('/api/areas/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe(
|
||||
"Area not found or doesn't belong to the current user."
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow access to other user's areas", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherArea = await Area.create({
|
||||
name: 'Other Area',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/areas/${otherArea.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe(
|
||||
"Area not found or doesn't belong to the current user."
|
||||
);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/areas/${area.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should get area by id', async () => {
|
||||
const response = await agent.get(`/api/areas/${area.id}`);
|
||||
describe('PATCH /api/areas/:id', () => {
|
||||
let area;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(area.id);
|
||||
expect(response.body.name).toBe(area.name);
|
||||
expect(response.body.description).toBe(area.description);
|
||||
beforeEach(async () => {
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
description: 'Work projects',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update area', async () => {
|
||||
const updateData = {
|
||||
name: 'Updated Work',
|
||||
description: 'Updated description',
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/areas/${area.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
expect(response.body.description).toBe(updateData.description);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/areas/999999')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it("should not allow updating other user's areas", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherArea = await Area.create({
|
||||
name: 'Other Area',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/areas/${otherArea.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/areas/${area.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent.get('/api/areas/999999');
|
||||
describe('DELETE /api/areas/:id', () => {
|
||||
let area;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe("Area not found or doesn't belong to the current user.");
|
||||
beforeEach(async () => {
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete area', async () => {
|
||||
const response = await agent.delete(`/api/areas/${area.id}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Verify area is deleted
|
||||
const deletedArea = await Area.findByPk(area.id);
|
||||
expect(deletedArea).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent.delete('/api/areas/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it("should not allow deleting other user's areas", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherArea = await Area.create({
|
||||
name: 'Other Area',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/areas/${otherArea.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/areas/${area.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow access to other user\'s areas', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherArea = await Area.create({
|
||||
name: 'Other Area',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/areas/${otherArea.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe("Area not found or doesn't belong to the current user.");
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/areas/${area.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/areas/:id', () => {
|
||||
let area;
|
||||
|
||||
beforeEach(async () => {
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
description: 'Work projects',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should update area', async () => {
|
||||
const updateData = {
|
||||
name: 'Updated Work',
|
||||
description: 'Updated description'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/areas/${area.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
expect(response.body.description).toBe(updateData.description);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/areas/999999')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it('should not allow updating other user\'s areas', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherArea = await Area.create({
|
||||
name: 'Other Area',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/areas/${otherArea.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/areas/${area.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/areas/:id', () => {
|
||||
let area;
|
||||
|
||||
beforeEach(async () => {
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete area', async () => {
|
||||
const response = await agent.delete(`/api/areas/${area.id}`);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
// Verify area is deleted
|
||||
const deletedArea = await Area.findByPk(area.id);
|
||||
expect(deletedArea).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent area', async () => {
|
||||
const response = await agent.delete('/api/areas/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it('should not allow deleting other user\'s areas', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherArea = await Area.create({
|
||||
name: 'Other Area',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/areas/${otherArea.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Area not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/areas/${area.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,152 +4,138 @@ const { User } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Auth Routes', () => {
|
||||
describe('POST /api/login', () => {
|
||||
let user;
|
||||
describe('POST /api/login', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should login with valid credentials', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeDefined();
|
||||
expect(response.body.user.email).toBe('test@example.com');
|
||||
expect(response.body.user.id).toBe(user.id);
|
||||
expect(response.body.user.language).toBe('en');
|
||||
expect(response.body.user.appearance).toBe('light');
|
||||
expect(response.body.user.timezone).toBe('UTC');
|
||||
});
|
||||
it('should login with valid credentials', async () => {
|
||||
const response = await request(app).post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
it('should return 400 for missing email', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/login')
|
||||
.send({
|
||||
password: 'password123'
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeDefined();
|
||||
expect(response.body.user.email).toBe('test@example.com');
|
||||
expect(response.body.user.id).toBe(user.id);
|
||||
expect(response.body.user.language).toBe('en');
|
||||
expect(response.body.user.appearance).toBe('light');
|
||||
expect(response.body.user.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid login parameters.');
|
||||
});
|
||||
it('should return 400 for missing email', async () => {
|
||||
const response = await request(app).post('/api/login').send({
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
it('should return 400 for missing password', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com'
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid login parameters.');
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid login parameters.');
|
||||
});
|
||||
it('should return 400 for missing password', async () => {
|
||||
const response = await request(app).post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
it('should return 401 for non-existent user', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'password123'
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid login parameters.');
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.errors).toEqual(['Invalid credentials']);
|
||||
});
|
||||
it('should return 401 for non-existent user', async () => {
|
||||
const response = await request(app).post('/api/login').send({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
it('should return 401 for invalid password', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword'
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.errors).toEqual(['Invalid credentials']);
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.errors).toEqual(['Invalid credentials']);
|
||||
});
|
||||
});
|
||||
it('should return 401 for invalid password', async () => {
|
||||
const response = await request(app).post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'wrongpassword',
|
||||
});
|
||||
|
||||
describe('GET /api/current_user', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.errors).toEqual(['Invalid credentials']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return current user when logged in', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
// Login first
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
describe('GET /api/current_user', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
// Check current user
|
||||
const response = await agent.get('/api/current_user');
|
||||
it('should return current user when logged in', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeDefined();
|
||||
expect(response.body.user.email).toBe('test@example.com');
|
||||
expect(response.body.user.id).toBe(user.id);
|
||||
});
|
||||
// Login first
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
it('should return null user when not logged in', async () => {
|
||||
const response = await request(app).get('/api/current_user');
|
||||
// Check current user
|
||||
const response = await agent.get('/api/current_user');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/logout', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('should logout successfully', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
// Login first
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeDefined();
|
||||
expect(response.body.user.email).toBe('test@example.com');
|
||||
expect(response.body.user.id).toBe(user.id);
|
||||
});
|
||||
|
||||
// Logout
|
||||
const response = await agent.get('/api/logout');
|
||||
it('should return null user when not logged in', async () => {
|
||||
const response = await request(app).get('/api/current_user');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
|
||||
// Verify user is logged out
|
||||
const currentUserResponse = await agent.get('/api/current_user');
|
||||
expect(currentUserResponse.body.user).toBeNull();
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle logout when not logged in', async () => {
|
||||
const response = await request(app).get('/api/logout');
|
||||
describe('GET /api/logout', () => {
|
||||
let user;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should logout successfully', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
// Login first
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
// Logout
|
||||
const response = await agent.get('/api/logout');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
|
||||
// Verify user is logged out
|
||||
const currentUserResponse = await agent.get('/api/current_user');
|
||||
expect(currentUserResponse.body.user).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle logout when not logged in', async () => {
|
||||
const response = await request(app).get('/api/logout');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Logged out successfully');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,345 +4,349 @@ const { InboxItem, Tag } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Inbox Routes - No Tags Scenario', () => {
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
|
||||
// Ensure no tags exist for this user (clean slate)
|
||||
await Tag.destroy({ where: { user_id: user.id } });
|
||||
});
|
||||
|
||||
describe('GET /api/inbox - No Tags Scenario', () => {
|
||||
it('should return empty inbox when no items exist and no tags exist', async () => {
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return inbox items even when no tags exist in system', async () => {
|
||||
// Create inbox items without any tags in the system
|
||||
const inboxItem1 = await InboxItem.create({
|
||||
content: 'Test item without tags',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const inboxItem2 = await InboxItem.create({
|
||||
content: 'Another item without tags',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(2);
|
||||
expect(response.body.map(item => item.id)).toContain(inboxItem1.id);
|
||||
expect(response.body.map(item => item.id)).toContain(inboxItem2.id);
|
||||
expect(response.body[0].content).toBeDefined();
|
||||
expect(response.body[0].status).toBe('added');
|
||||
expect(response.body[0].user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should handle mixed inbox items when no tags exist', async () => {
|
||||
// Create inbox items with different statuses
|
||||
const addedItem = await InboxItem.create({
|
||||
content: 'Added item',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
await InboxItem.create({
|
||||
content: 'Processed item',
|
||||
status: 'processed',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
await InboxItem.create({
|
||||
content: 'Deleted item',
|
||||
status: 'deleted',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(1); // Only 'added' items should be returned
|
||||
expect(response.body[0].id).toBe(addedItem.id);
|
||||
expect(response.body[0].status).toBe('added');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tags - No Tags Scenario', () => {
|
||||
it('should return empty array when no tags exist', async () => {
|
||||
const response = await agent.get('/api/tags');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not affect inbox functionality when tags endpoint returns empty', async () => {
|
||||
// Create inbox item
|
||||
await InboxItem.create({
|
||||
content: 'Test item',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
// Verify tags endpoint returns empty
|
||||
const tagsResponse = await agent.get('/api/tags');
|
||||
expect(tagsResponse.status).toBe(200);
|
||||
expect(tagsResponse.body.length).toBe(0);
|
||||
|
||||
// Verify inbox still works
|
||||
const inboxResponse = await agent.get('/api/inbox');
|
||||
expect(inboxResponse.status).toBe(200);
|
||||
expect(inboxResponse.body.length).toBe(1);
|
||||
expect(inboxResponse.body[0].content).toBe('Test item');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/inbox - No Tags Scenario', () => {
|
||||
it('should create inbox items successfully when no tags exist', async () => {
|
||||
const inboxData = {
|
||||
content: 'New inbox item without tags',
|
||||
source: 'web'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/inbox')
|
||||
.send(inboxData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.content).toBe(inboxData.content);
|
||||
expect(response.body.source).toBe(inboxData.source);
|
||||
expect(response.body.status).toBe('added');
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should handle multiple inbox items creation when no tags exist', async () => {
|
||||
const items = [
|
||||
{ content: 'First item', source: 'web' },
|
||||
{ content: 'Second item', source: 'telegram' },
|
||||
{ content: 'Third item', source: 'api' }
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
const response = await agent
|
||||
.post('/api/inbox')
|
||||
.send(item);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.content).toBe(item.content);
|
||||
expect(response.body.source).toBe(item.source);
|
||||
}
|
||||
|
||||
// Verify all items are retrievable
|
||||
const getResponse = await agent.get('/api/inbox');
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Original content',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
// Ensure no tags exist for this user (clean slate)
|
||||
await Tag.destroy({ where: { user_id: user.id } });
|
||||
});
|
||||
|
||||
it('should update inbox items when no tags exist', async () => {
|
||||
const updateData = {
|
||||
content: 'Updated content without tags',
|
||||
status: 'processed'
|
||||
};
|
||||
describe('GET /api/inbox - No Tags Scenario', () => {
|
||||
it('should return empty inbox when no items exist and no tags exist', async () => {
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.send(updateData);
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(0);
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.content).toBe(updateData.content);
|
||||
expect(response.body.status).toBe(updateData.status);
|
||||
});
|
||||
});
|
||||
it('should return inbox items even when no tags exist in system', async () => {
|
||||
// Create inbox items without any tags in the system
|
||||
const inboxItem1 = await InboxItem.create({
|
||||
content: 'Test item without tags',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id/process - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
const inboxItem2 = await InboxItem.create({
|
||||
content: 'Another item without tags',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Item to process',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(2);
|
||||
expect(response.body.map((item) => item.id)).toContain(
|
||||
inboxItem1.id
|
||||
);
|
||||
expect(response.body.map((item) => item.id)).toContain(
|
||||
inboxItem2.id
|
||||
);
|
||||
expect(response.body[0].content).toBeDefined();
|
||||
expect(response.body[0].status).toBe('added');
|
||||
expect(response.body[0].user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should handle mixed inbox items when no tags exist', async () => {
|
||||
// Create inbox items with different statuses
|
||||
const addedItem = await InboxItem.create({
|
||||
content: 'Added item',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await InboxItem.create({
|
||||
content: 'Processed item',
|
||||
status: 'processed',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await InboxItem.create({
|
||||
content: 'Deleted item',
|
||||
status: 'deleted',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(1); // Only 'added' items should be returned
|
||||
expect(response.body[0].id).toBe(addedItem.id);
|
||||
expect(response.body[0].status).toBe('added');
|
||||
});
|
||||
});
|
||||
|
||||
it('should process inbox items when no tags exist', async () => {
|
||||
const response = await agent.patch(`/api/inbox/${inboxItem.id}/process`);
|
||||
describe('GET /api/tags - No Tags Scenario', () => {
|
||||
it('should return empty array when no tags exist', async () => {
|
||||
const response = await agent.get('/api/tags');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('processed');
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('DELETE /api/inbox/:id - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
it('should not affect inbox functionality when tags endpoint returns empty', async () => {
|
||||
// Create inbox item
|
||||
await InboxItem.create({
|
||||
content: 'Test item',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Item to delete',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
// Verify tags endpoint returns empty
|
||||
const tagsResponse = await agent.get('/api/tags');
|
||||
expect(tagsResponse.status).toBe(200);
|
||||
expect(tagsResponse.body.length).toBe(0);
|
||||
|
||||
// Verify inbox still works
|
||||
const inboxResponse = await agent.get('/api/inbox');
|
||||
expect(inboxResponse.status).toBe(200);
|
||||
expect(inboxResponse.body.length).toBe(1);
|
||||
expect(inboxResponse.body[0].content).toBe('Test item');
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete inbox items when no tags exist', async () => {
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
|
||||
describe('POST /api/inbox - No Tags Scenario', () => {
|
||||
it('should create inbox items successfully when no tags exist', async () => {
|
||||
const inboxData = {
|
||||
content: 'New inbox item without tags',
|
||||
source: 'web',
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Inbox item successfully deleted');
|
||||
const response = await agent.post('/api/inbox').send(inboxData);
|
||||
|
||||
// Verify item status is updated to deleted
|
||||
const deletedItem = await InboxItem.findByPk(inboxItem.id);
|
||||
expect(deletedItem).not.toBeNull();
|
||||
expect(deletedItem.status).toBe('deleted');
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.content).toBe(inboxData.content);
|
||||
expect(response.body.source).toBe(inboxData.source);
|
||||
expect(response.body.status).toBe('added');
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
describe('Full Workflow - No Tags Scenario', () => {
|
||||
it('should support complete inbox workflow without any tags in system', async () => {
|
||||
// Step 1: Verify no tags exist
|
||||
const tagsResponse = await agent.get('/api/tags');
|
||||
expect(tagsResponse.status).toBe(200);
|
||||
expect(tagsResponse.body.length).toBe(0);
|
||||
it('should handle multiple inbox items creation when no tags exist', async () => {
|
||||
const items = [
|
||||
{ content: 'First item', source: 'web' },
|
||||
{ content: 'Second item', source: 'telegram' },
|
||||
{ content: 'Third item', source: 'api' },
|
||||
];
|
||||
|
||||
// Step 2: Create inbox item
|
||||
const createResponse = await agent
|
||||
.post('/api/inbox')
|
||||
.send({ content: 'Complete workflow test', source: 'web' });
|
||||
expect(createResponse.status).toBe(201);
|
||||
const itemId = createResponse.body.id;
|
||||
for (const item of items) {
|
||||
const response = await agent.post('/api/inbox').send(item);
|
||||
|
||||
// Step 3: Retrieve inbox items
|
||||
const getResponse = await agent.get('/api/inbox');
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.length).toBe(1);
|
||||
expect(getResponse.body[0].id).toBe(itemId);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.content).toBe(item.content);
|
||||
expect(response.body.source).toBe(item.source);
|
||||
}
|
||||
|
||||
// Step 4: Update inbox item
|
||||
const updateResponse = await agent
|
||||
.patch(`/api/inbox/${itemId}`)
|
||||
.send({ content: 'Updated workflow test' });
|
||||
expect(updateResponse.status).toBe(200);
|
||||
expect(updateResponse.body.content).toBe('Updated workflow test');
|
||||
|
||||
// Step 5: Process inbox item
|
||||
const processResponse = await agent.patch(`/api/inbox/${itemId}/process`);
|
||||
expect(processResponse.status).toBe(200);
|
||||
expect(processResponse.body.status).toBe('processed');
|
||||
|
||||
// Step 6: Verify processed item is not in main inbox list
|
||||
const finalGetResponse = await agent.get('/api/inbox');
|
||||
expect(finalGetResponse.status).toBe(200);
|
||||
expect(finalGetResponse.body.length).toBe(0); // Processed items don't appear in inbox
|
||||
// Verify all items are retrievable
|
||||
const getResponse = await agent.get('/api/inbox');
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle concurrent operations when no tags exist', async () => {
|
||||
// Create multiple items concurrently
|
||||
const createPromises = Array.from({ length: 5 }, (_, i) =>
|
||||
agent.post('/api/inbox').send({
|
||||
content: `Concurrent item ${i + 1}`,
|
||||
source: 'test'
|
||||
})
|
||||
);
|
||||
describe('PATCH /api/inbox/:id - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
|
||||
const createResponses = await Promise.all(createPromises);
|
||||
|
||||
// All should succeed
|
||||
createResponses.forEach(response => {
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Original content',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify all items are retrievable
|
||||
const getResponse = await agent.get('/api/inbox');
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.length).toBe(5);
|
||||
it('should update inbox items when no tags exist', async () => {
|
||||
const updateData = {
|
||||
content: 'Updated content without tags',
|
||||
status: 'processed',
|
||||
};
|
||||
|
||||
// Process all items concurrently
|
||||
const itemIds = createResponses.map(response => response.body.id);
|
||||
const processPromises = itemIds.map(id =>
|
||||
agent.patch(`/api/inbox/${id}/process`)
|
||||
);
|
||||
const response = await agent
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.send(updateData);
|
||||
|
||||
const processResponses = await Promise.all(processPromises);
|
||||
|
||||
// All should succeed
|
||||
processResponses.forEach(response => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('processed');
|
||||
});
|
||||
|
||||
// Verify no items remain in inbox
|
||||
const finalGetResponse = await agent.get('/api/inbox');
|
||||
expect(finalGetResponse.status).toBe(200);
|
||||
expect(finalGetResponse.body.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling - No Tags Scenario', () => {
|
||||
it('should handle invalid inbox item operations gracefully when no tags exist', async () => {
|
||||
// Try to get non-existent item
|
||||
const getResponse = await agent.get('/api/inbox/999999');
|
||||
expect(getResponse.status).toBe(404);
|
||||
expect(getResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to update non-existent item
|
||||
const updateResponse = await agent
|
||||
.patch('/api/inbox/999999')
|
||||
.send({ content: 'Updated' });
|
||||
expect(updateResponse.status).toBe(404);
|
||||
expect(updateResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to process non-existent item
|
||||
const processResponse = await agent.patch('/api/inbox/999999/process');
|
||||
expect(processResponse.status).toBe(404);
|
||||
expect(processResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to delete non-existent item
|
||||
const deleteResponse = await agent.delete('/api/inbox/999999');
|
||||
expect(deleteResponse.status).toBe(404);
|
||||
expect(deleteResponse.body.error).toBe('Inbox item not found.');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.content).toBe(updateData.content);
|
||||
expect(response.body.status).toBe(updateData.status);
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate required fields when creating inbox items (no tags scenario)', async () => {
|
||||
// Try to create item without content
|
||||
const response = await agent
|
||||
.post('/api/inbox')
|
||||
.send({});
|
||||
describe('PATCH /api/inbox/:id/process - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Content is required');
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Item to process',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should process inbox items when no tags exist', async () => {
|
||||
const response = await agent.patch(
|
||||
`/api/inbox/${inboxItem.id}/process`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('processed');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/inbox/:id - No Tags Scenario', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Item to delete',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete inbox items when no tags exist', async () => {
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(
|
||||
'Inbox item successfully deleted'
|
||||
);
|
||||
|
||||
// Verify item status is updated to deleted
|
||||
const deletedItem = await InboxItem.findByPk(inboxItem.id);
|
||||
expect(deletedItem).not.toBeNull();
|
||||
expect(deletedItem.status).toBe('deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full Workflow - No Tags Scenario', () => {
|
||||
it('should support complete inbox workflow without any tags in system', async () => {
|
||||
// Step 1: Verify no tags exist
|
||||
const tagsResponse = await agent.get('/api/tags');
|
||||
expect(tagsResponse.status).toBe(200);
|
||||
expect(tagsResponse.body.length).toBe(0);
|
||||
|
||||
// Step 2: Create inbox item
|
||||
const createResponse = await agent
|
||||
.post('/api/inbox')
|
||||
.send({ content: 'Complete workflow test', source: 'web' });
|
||||
expect(createResponse.status).toBe(201);
|
||||
const itemId = createResponse.body.id;
|
||||
|
||||
// Step 3: Retrieve inbox items
|
||||
const getResponse = await agent.get('/api/inbox');
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.length).toBe(1);
|
||||
expect(getResponse.body[0].id).toBe(itemId);
|
||||
|
||||
// Step 4: Update inbox item
|
||||
const updateResponse = await agent
|
||||
.patch(`/api/inbox/${itemId}`)
|
||||
.send({ content: 'Updated workflow test' });
|
||||
expect(updateResponse.status).toBe(200);
|
||||
expect(updateResponse.body.content).toBe('Updated workflow test');
|
||||
|
||||
// Step 5: Process inbox item
|
||||
const processResponse = await agent.patch(
|
||||
`/api/inbox/${itemId}/process`
|
||||
);
|
||||
expect(processResponse.status).toBe(200);
|
||||
expect(processResponse.body.status).toBe('processed');
|
||||
|
||||
// Step 6: Verify processed item is not in main inbox list
|
||||
const finalGetResponse = await agent.get('/api/inbox');
|
||||
expect(finalGetResponse.status).toBe(200);
|
||||
expect(finalGetResponse.body.length).toBe(0); // Processed items don't appear in inbox
|
||||
});
|
||||
|
||||
it('should handle concurrent operations when no tags exist', async () => {
|
||||
// Create multiple items concurrently
|
||||
const createPromises = Array.from({ length: 5 }, (_, i) =>
|
||||
agent.post('/api/inbox').send({
|
||||
content: `Concurrent item ${i + 1}`,
|
||||
source: 'test',
|
||||
})
|
||||
);
|
||||
|
||||
const createResponses = await Promise.all(createPromises);
|
||||
|
||||
// All should succeed
|
||||
createResponses.forEach((response) => {
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
|
||||
// Verify all items are retrievable
|
||||
const getResponse = await agent.get('/api/inbox');
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(getResponse.body.length).toBe(5);
|
||||
|
||||
// Process all items concurrently
|
||||
const itemIds = createResponses.map((response) => response.body.id);
|
||||
const processPromises = itemIds.map((id) =>
|
||||
agent.patch(`/api/inbox/${id}/process`)
|
||||
);
|
||||
|
||||
const processResponses = await Promise.all(processPromises);
|
||||
|
||||
// All should succeed
|
||||
processResponses.forEach((response) => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('processed');
|
||||
});
|
||||
|
||||
// Verify no items remain in inbox
|
||||
const finalGetResponse = await agent.get('/api/inbox');
|
||||
expect(finalGetResponse.status).toBe(200);
|
||||
expect(finalGetResponse.body.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling - No Tags Scenario', () => {
|
||||
it('should handle invalid inbox item operations gracefully when no tags exist', async () => {
|
||||
// Try to get non-existent item
|
||||
const getResponse = await agent.get('/api/inbox/999999');
|
||||
expect(getResponse.status).toBe(404);
|
||||
expect(getResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to update non-existent item
|
||||
const updateResponse = await agent
|
||||
.patch('/api/inbox/999999')
|
||||
.send({ content: 'Updated' });
|
||||
expect(updateResponse.status).toBe(404);
|
||||
expect(updateResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to process non-existent item
|
||||
const processResponse = await agent.patch(
|
||||
'/api/inbox/999999/process'
|
||||
);
|
||||
expect(processResponse.status).toBe(404);
|
||||
expect(processResponse.body.error).toBe('Inbox item not found.');
|
||||
|
||||
// Try to delete non-existent item
|
||||
const deleteResponse = await agent.delete('/api/inbox/999999');
|
||||
expect(deleteResponse.status).toBe(404);
|
||||
expect(deleteResponse.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should validate required fields when creating inbox items (no tags scenario)', async () => {
|
||||
// Try to create item without content
|
||||
const response = await agent.post('/api/inbox').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Content is required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,272 +4,276 @@ const { InboxItem, User } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Inbox Routes', () => {
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/inbox', () => {
|
||||
it('should create a new inbox item', async () => {
|
||||
const inboxData = {
|
||||
content: 'Remember to buy groceries',
|
||||
source: 'web'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/inbox')
|
||||
.send(inboxData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.content).toBe(inboxData.content);
|
||||
expect(response.body.source).toBe(inboxData.source);
|
||||
expect(response.body.status).toBe('added');
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/inbox')
|
||||
.send(inboxData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require content', async () => {
|
||||
const inboxData = {};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/inbox')
|
||||
.send(inboxData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Content is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/inbox', () => {
|
||||
let inboxItem1, inboxItem2;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem1 = await InboxItem.create({
|
||||
content: 'First item',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
inboxItem2 = await InboxItem.create({
|
||||
content: 'Second item',
|
||||
status: 'processed',
|
||||
user_id: user.id
|
||||
});
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user inbox items', async () => {
|
||||
const response = await agent.get('/api/inbox');
|
||||
describe('POST /api/inbox', () => {
|
||||
it('should create a new inbox item', async () => {
|
||||
const inboxData = {
|
||||
content: 'Remember to buy groceries',
|
||||
source: 'web',
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(1); // Only items with status 'added' are returned
|
||||
expect(response.body.map(i => i.id)).toContain(inboxItem1.id);
|
||||
const response = await agent.post('/api/inbox').send(inboxData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.content).toBe(inboxData.content);
|
||||
expect(response.body.source).toBe(inboxData.source);
|
||||
expect(response.body.status).toBe('added');
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/inbox')
|
||||
.send(inboxData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require content', async () => {
|
||||
const inboxData = {};
|
||||
|
||||
const response = await agent.post('/api/inbox').send(inboxData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Content is required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should only return items with added status', async () => {
|
||||
const response = await agent.get('/api/inbox');
|
||||
describe('GET /api/inbox', () => {
|
||||
let inboxItem1, inboxItem2;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(1);
|
||||
expect(response.body[0].id).toBe(inboxItem1.id);
|
||||
expect(response.body[0].status).toBe('added');
|
||||
beforeEach(async () => {
|
||||
inboxItem1 = await InboxItem.create({
|
||||
content: 'First item',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
inboxItem2 = await InboxItem.create({
|
||||
content: 'Second item',
|
||||
status: 'processed',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user inbox items', async () => {
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(1); // Only items with status 'added' are returned
|
||||
expect(response.body.map((i) => i.id)).toContain(inboxItem1.id);
|
||||
});
|
||||
|
||||
it('should only return items with added status', async () => {
|
||||
const response = await agent.get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(1);
|
||||
expect(response.body[0].id).toBe(inboxItem1.id);
|
||||
expect(response.body[0].status).toBe('added');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/inbox');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/inbox');
|
||||
describe('GET /api/inbox/:id', () => {
|
||||
let inboxItem;
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/inbox/:id', () => {
|
||||
let inboxItem;
|
||||
it('should get inbox item by id', async () => {
|
||||
const response = await agent.get(`/api/inbox/${inboxItem.id}`);
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(inboxItem.id);
|
||||
expect(response.body.content).toBe(inboxItem.content);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.get('/api/inbox/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it("should not allow access to other user's inbox items", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherInboxItem = await InboxItem.create({
|
||||
content: 'Other content',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/inbox/${otherInboxItem.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(
|
||||
`/api/inbox/${inboxItem.id}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should get inbox item by id', async () => {
|
||||
const response = await agent.get(`/api/inbox/${inboxItem.id}`);
|
||||
describe('PATCH /api/inbox/:id', () => {
|
||||
let inboxItem;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(inboxItem.id);
|
||||
expect(response.body.content).toBe(inboxItem.content);
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update inbox item', async () => {
|
||||
const updateData = {
|
||||
content: 'Updated content',
|
||||
status: 'processed',
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.content).toBe(updateData.content);
|
||||
expect(response.body.status).toBe(updateData.status);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/inbox/999999')
|
||||
.send({ content: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.send({ content: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.get('/api/inbox/999999');
|
||||
describe('DELETE /api/inbox/:id', () => {
|
||||
let inboxItem;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete inbox item', async () => {
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(
|
||||
'Inbox item successfully deleted'
|
||||
);
|
||||
|
||||
// Verify inbox item status is updated to deleted
|
||||
const deletedItem = await InboxItem.findByPk(inboxItem.id);
|
||||
expect(deletedItem).not.toBeNull();
|
||||
expect(deletedItem.status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.delete('/api/inbox/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(
|
||||
`/api/inbox/${inboxItem.id}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow access to other user\'s inbox items', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
describe('PATCH /api/inbox/:id/process', () => {
|
||||
let inboxItem;
|
||||
|
||||
const otherInboxItem = await InboxItem.create({
|
||||
content: 'Other content',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
status: 'added',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/inbox/${otherInboxItem.id}`);
|
||||
it('should process inbox item', async () => {
|
||||
const response = await agent.patch(
|
||||
`/api/inbox/${inboxItem.id}/process`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('processed');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.patch('/api/inbox/999999/process');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).patch(
|
||||
`/api/inbox/${inboxItem.id}/process`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/inbox/${inboxItem.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should update inbox item', async () => {
|
||||
const updateData = {
|
||||
content: 'Updated content',
|
||||
status: 'processed'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.content).toBe(updateData.content);
|
||||
expect(response.body.status).toBe(updateData.status);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/inbox/999999')
|
||||
.send({ content: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/inbox/${inboxItem.id}`)
|
||||
.send({ content: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/inbox/:id', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete inbox item', async () => {
|
||||
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Inbox item successfully deleted');
|
||||
|
||||
// Verify inbox item status is updated to deleted
|
||||
const deletedItem = await InboxItem.findByPk(inboxItem.id);
|
||||
expect(deletedItem).not.toBeNull();
|
||||
expect(deletedItem.status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.delete('/api/inbox/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/inbox/${inboxItem.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/inbox/:id/process', () => {
|
||||
let inboxItem;
|
||||
|
||||
beforeEach(async () => {
|
||||
inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
status: 'added',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should process inbox item', async () => {
|
||||
const response = await agent.patch(`/api/inbox/${inboxItem.id}/process`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('processed');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent inbox item', async () => {
|
||||
const response = await agent.patch('/api/inbox/999999/process');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Inbox item not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).patch(`/api/inbox/${inboxItem.id}/process`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,305 +4,301 @@ const { Note, User, Project } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Notes Routes', () => {
|
||||
let user, project, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/note', () => {
|
||||
it('should create a new note', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'This is a test note content',
|
||||
project_id: project.id
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/note')
|
||||
.send(noteData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.title).toBe(noteData.title);
|
||||
expect(response.body.content).toBe(noteData.content);
|
||||
expect(response.body.project_id).toBe(project.id);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should create note without project', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'This is a test note content'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/note')
|
||||
.send(noteData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.title).toBe(noteData.title);
|
||||
expect(response.body.content).toBe(noteData.content);
|
||||
expect(response.body.project_id).toBeNull();
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'Test content'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/note')
|
||||
.send(noteData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/notes', () => {
|
||||
let note1, note2;
|
||||
let user, project, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
note1 = await Note.create({
|
||||
title: 'Note 1',
|
||||
content: 'First note content',
|
||||
user_id: user.id,
|
||||
project_id: project.id
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
note2 = await Note.create({
|
||||
title: 'Note 2',
|
||||
content: 'Second note content',
|
||||
user_id: user.id
|
||||
});
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user notes', async () => {
|
||||
const response = await agent.get('/api/notes');
|
||||
describe('POST /api/note', () => {
|
||||
it('should create a new note', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'This is a test note content',
|
||||
project_id: project.id,
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(2);
|
||||
expect(response.body.map(n => n.id)).toContain(note1.id);
|
||||
expect(response.body.map(n => n.id)).toContain(note2.id);
|
||||
const response = await agent.post('/api/note').send(noteData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.title).toBe(noteData.title);
|
||||
expect(response.body.content).toBe(noteData.content);
|
||||
expect(response.body.project_id).toBe(project.id);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should create note without project', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'This is a test note content',
|
||||
};
|
||||
|
||||
const response = await agent.post('/api/note').send(noteData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.title).toBe(noteData.title);
|
||||
expect(response.body.content).toBe(noteData.content);
|
||||
expect(response.body.project_id).toBeNull();
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/note')
|
||||
.send(noteData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include project information', async () => {
|
||||
const response = await agent.get('/api/notes');
|
||||
describe('GET /api/notes', () => {
|
||||
let note1, note2;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const noteWithProject = response.body.find(n => n.id === note1.id);
|
||||
expect(noteWithProject.Project).toBeDefined();
|
||||
expect(noteWithProject.Project.name).toBe(project.name);
|
||||
beforeEach(async () => {
|
||||
note1 = await Note.create({
|
||||
title: 'Note 1',
|
||||
content: 'First note content',
|
||||
user_id: user.id,
|
||||
project_id: project.id,
|
||||
});
|
||||
|
||||
note2 = await Note.create({
|
||||
title: 'Note 2',
|
||||
content: 'Second note content',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user notes', async () => {
|
||||
const response = await agent.get('/api/notes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body.length).toBe(2);
|
||||
expect(response.body.map((n) => n.id)).toContain(note1.id);
|
||||
expect(response.body.map((n) => n.id)).toContain(note2.id);
|
||||
});
|
||||
|
||||
it('should include project information', async () => {
|
||||
const response = await agent.get('/api/notes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const noteWithProject = response.body.find(
|
||||
(n) => n.id === note1.id
|
||||
);
|
||||
expect(noteWithProject.Project).toBeDefined();
|
||||
expect(noteWithProject.Project.name).toBe(project.name);
|
||||
});
|
||||
|
||||
it('should return all notes when no filter is applied', async () => {
|
||||
const response = await agent.get('/api/notes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(2);
|
||||
expect(response.body.map((n) => n.id)).toContain(note1.id);
|
||||
expect(response.body.map((n) => n.id)).toContain(note2.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/notes');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return all notes when no filter is applied', async () => {
|
||||
const response = await agent.get('/api/notes');
|
||||
describe('GET /api/note/:id', () => {
|
||||
let note;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.length).toBe(2);
|
||||
expect(response.body.map(n => n.id)).toContain(note1.id);
|
||||
expect(response.body.map(n => n.id)).toContain(note2.id);
|
||||
beforeEach(async () => {
|
||||
note = await Note.create({
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
project_id: project.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get note by id', async () => {
|
||||
const response = await agent.get(`/api/note/${note.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(note.id);
|
||||
expect(response.body.title).toBe(note.title);
|
||||
expect(response.body.content).toBe(note.content);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent.get('/api/note/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it("should not allow access to other user's notes", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherNote = await Note.create({
|
||||
title: 'Other Note',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/note/${otherNote.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/note/${note.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/notes');
|
||||
describe('PATCH /api/note/:id', () => {
|
||||
let note;
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
beforeEach(async () => {
|
||||
note = await Note.create({
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/note/:id', () => {
|
||||
let note;
|
||||
it('should update note', async () => {
|
||||
const updateData = {
|
||||
title: 'Updated Note',
|
||||
content: 'Updated content',
|
||||
project_id: project.id,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
note = await Note.create({
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
project_id: project.id
|
||||
});
|
||||
const response = await agent
|
||||
.patch(`/api/note/${note.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.title).toBe(updateData.title);
|
||||
expect(response.body.content).toBe(updateData.content);
|
||||
expect(response.body.project_id).toBe(project.id);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/note/999999')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it("should not allow updating other user's notes", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherNote = await Note.create({
|
||||
title: 'Other Note',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/note/${otherNote.id}`)
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/note/${note.id}`)
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should get note by id', async () => {
|
||||
const response = await agent.get(`/api/note/${note.id}`);
|
||||
describe('DELETE /api/note/:id', () => {
|
||||
let note;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(note.id);
|
||||
expect(response.body.title).toBe(note.title);
|
||||
expect(response.body.content).toBe(note.content);
|
||||
beforeEach(async () => {
|
||||
note = await Note.create({
|
||||
title: 'Test Note',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete note', async () => {
|
||||
const response = await agent.delete(`/api/note/${note.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Note deleted successfully.');
|
||||
|
||||
// Verify note is deleted
|
||||
const deletedNote = await Note.findByPk(note.id);
|
||||
expect(deletedNote).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent.delete('/api/note/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it("should not allow deleting other user's notes", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherNote = await Note.create({
|
||||
title: 'Other Note',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/note/${otherNote.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/note/${note.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent.get('/api/note/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should not allow access to other user\'s notes', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherNote = await Note.create({
|
||||
title: 'Other Note',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/note/${otherNote.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/note/${note.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/note/:id', () => {
|
||||
let note;
|
||||
|
||||
beforeEach(async () => {
|
||||
note = await Note.create({
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should update note', async () => {
|
||||
const updateData = {
|
||||
title: 'Updated Note',
|
||||
content: 'Updated content',
|
||||
project_id: project.id
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/note/${note.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.title).toBe(updateData.title);
|
||||
expect(response.body.content).toBe(updateData.content);
|
||||
expect(response.body.project_id).toBe(project.id);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/note/999999')
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should not allow updating other user\'s notes', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherNote = await Note.create({
|
||||
title: 'Other Note',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/note/${otherNote.id}`)
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/note/${note.id}`)
|
||||
.send({ title: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/note/:id', () => {
|
||||
let note;
|
||||
|
||||
beforeEach(async () => {
|
||||
note = await Note.create({
|
||||
title: 'Test Note',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete note', async () => {
|
||||
const response = await agent.delete(`/api/note/${note.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Note deleted successfully.');
|
||||
|
||||
// Verify note is deleted
|
||||
const deletedNote = await Note.findByPk(note.id);
|
||||
expect(deletedNote).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent note', async () => {
|
||||
const response = await agent.delete('/api/note/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should not allow deleting other user\'s notes', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherNote = await Note.create({
|
||||
title: 'Other Note',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/note/${otherNote.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Note not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/note/${note.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,300 +4,308 @@ const { Project, User, Area } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Projects Routes', () => {
|
||||
let user, area, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/project', () => {
|
||||
it('should create a new project', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
priority: 1,
|
||||
area_id: area.id
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/project')
|
||||
.send(projectData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(projectData.name);
|
||||
expect(response.body.description).toBe(projectData.description);
|
||||
expect(response.body.active).toBe(projectData.active);
|
||||
expect(response.body.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
|
||||
expect(response.body.priority).toBe(projectData.priority);
|
||||
expect(response.body.area_id).toBe(area.id);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/project')
|
||||
.send(projectData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require project name', async () => {
|
||||
const projectData = {
|
||||
description: 'Project without name'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/project')
|
||||
.send(projectData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/projects', () => {
|
||||
let project1, project2;
|
||||
let user, area, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
project1 = await Project.create({
|
||||
name: 'Project 1',
|
||||
description: 'First project',
|
||||
user_id: user.id,
|
||||
area_id: area.id
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
project2 = await Project.create({
|
||||
name: 'Project 2',
|
||||
description: 'Second project',
|
||||
user_id: user.id
|
||||
});
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user projects', async () => {
|
||||
const response = await agent.get('/api/projects');
|
||||
describe('POST /api/project', () => {
|
||||
it('should create a new project', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
priority: 1,
|
||||
area_id: area.id,
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.projects).toBeDefined();
|
||||
expect(response.body.projects.length).toBe(2);
|
||||
expect(response.body.projects.map(p => p.id)).toContain(project1.id);
|
||||
expect(response.body.projects.map(p => p.id)).toContain(project2.id);
|
||||
const response = await agent.post('/api/project').send(projectData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(projectData.name);
|
||||
expect(response.body.description).toBe(projectData.description);
|
||||
expect(response.body.active).toBe(projectData.active);
|
||||
expect(response.body.pin_to_sidebar).toBe(
|
||||
projectData.pin_to_sidebar
|
||||
);
|
||||
expect(response.body.priority).toBe(projectData.priority);
|
||||
expect(response.body.area_id).toBe(area.id);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/project')
|
||||
.send(projectData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require project name', async () => {
|
||||
const projectData = {
|
||||
description: 'Project without name',
|
||||
};
|
||||
|
||||
const response = await agent.post('/api/project').send(projectData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include area information', async () => {
|
||||
const response = await agent.get('/api/projects');
|
||||
describe('GET /api/projects', () => {
|
||||
let project1, project2;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const projectWithArea = response.body.projects.find(p => p.id === project1.id);
|
||||
expect(projectWithArea.Area).toBeDefined();
|
||||
expect(projectWithArea.Area.name).toBe(area.name);
|
||||
beforeEach(async () => {
|
||||
project1 = await Project.create({
|
||||
name: 'Project 1',
|
||||
description: 'First project',
|
||||
user_id: user.id,
|
||||
area_id: area.id,
|
||||
});
|
||||
|
||||
project2 = await Project.create({
|
||||
name: 'Project 2',
|
||||
description: 'Second project',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user projects', async () => {
|
||||
const response = await agent.get('/api/projects');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.projects).toBeDefined();
|
||||
expect(response.body.projects.length).toBe(2);
|
||||
expect(response.body.projects.map((p) => p.id)).toContain(
|
||||
project1.id
|
||||
);
|
||||
expect(response.body.projects.map((p) => p.id)).toContain(
|
||||
project2.id
|
||||
);
|
||||
});
|
||||
|
||||
it('should include area information', async () => {
|
||||
const response = await agent.get('/api/projects');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const projectWithArea = response.body.projects.find(
|
||||
(p) => p.id === project1.id
|
||||
);
|
||||
expect(projectWithArea.Area).toBeDefined();
|
||||
expect(projectWithArea.Area.name).toBe(area.name);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/projects');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/projects');
|
||||
describe('GET /api/project/:id', () => {
|
||||
let project;
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
beforeEach(async () => {
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
user_id: user.id,
|
||||
area_id: area.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/project/:id', () => {
|
||||
let project;
|
||||
it('should get project by id', async () => {
|
||||
const response = await agent.get(`/api/project/${project.id}`);
|
||||
|
||||
beforeEach(async () => {
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
user_id: user.id,
|
||||
area_id: area.id
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(project.id);
|
||||
expect(response.body.name).toBe(project.name);
|
||||
expect(response.body.description).toBe(project.description);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent.get('/api/project/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found');
|
||||
});
|
||||
|
||||
it("should not allow access to other user's projects", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherProject = await Project.create({
|
||||
name: 'Other Project',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/project/${otherProject.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(
|
||||
`/api/project/${project.id}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should get project by id', async () => {
|
||||
const response = await agent.get(`/api/project/${project.id}`);
|
||||
describe('PATCH /api/project/:id', () => {
|
||||
let project;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(project.id);
|
||||
expect(response.body.name).toBe(project.name);
|
||||
expect(response.body.description).toBe(project.description);
|
||||
beforeEach(async () => {
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: false,
|
||||
priority: 0,
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update project', async () => {
|
||||
const updateData = {
|
||||
name: 'Updated Project',
|
||||
description: 'Updated Description',
|
||||
active: true,
|
||||
priority: 2,
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/project/${project.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
expect(response.body.description).toBe(updateData.description);
|
||||
expect(response.body.active).toBe(updateData.active);
|
||||
expect(response.body.priority).toBe(updateData.priority);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/project/999999')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it("should not allow updating other user's projects", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherProject = await Project.create({
|
||||
name: 'Other Project',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/project/${otherProject.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/project/${project.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent.get('/api/project/999999');
|
||||
describe('DELETE /api/project/:id', () => {
|
||||
let project;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found');
|
||||
beforeEach(async () => {
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete project', async () => {
|
||||
const response = await agent.delete(`/api/project/${project.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Project successfully deleted');
|
||||
|
||||
// Verify project is deleted
|
||||
const deletedProject = await Project.findByPk(project.id);
|
||||
expect(deletedProject).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent.delete('/api/project/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it("should not allow deleting other user's projects", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherProject = await Project.create({
|
||||
name: 'Other Project',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(
|
||||
`/api/project/${otherProject.id}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(
|
||||
`/api/project/${project.id}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow access to other user\'s projects', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherProject = await Project.create({
|
||||
name: 'Other Project',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/project/${otherProject.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/project/${project.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/project/:id', () => {
|
||||
let project;
|
||||
|
||||
beforeEach(async () => {
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: false,
|
||||
priority: 0,
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should update project', async () => {
|
||||
const updateData = {
|
||||
name: 'Updated Project',
|
||||
description: 'Updated Description',
|
||||
active: true,
|
||||
priority: 2
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/project/${project.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
expect(response.body.description).toBe(updateData.description);
|
||||
expect(response.body.active).toBe(updateData.active);
|
||||
expect(response.body.priority).toBe(updateData.priority);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/project/999999')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it('should not allow updating other user\'s projects', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherProject = await Project.create({
|
||||
name: 'Other Project',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/project/${otherProject.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/project/${project.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/project/:id', () => {
|
||||
let project;
|
||||
|
||||
beforeEach(async () => {
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete project', async () => {
|
||||
const response = await agent.delete(`/api/project/${project.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Project successfully deleted');
|
||||
|
||||
// Verify project is deleted
|
||||
const deletedProject = await Project.findByPk(project.id);
|
||||
expect(deletedProject).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent project', async () => {
|
||||
const response = await agent.delete('/api/project/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it('should not allow deleting other user\'s projects', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherProject = await Project.create({
|
||||
name: 'Other Project',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/project/${otherProject.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Project not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/project/${project.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,166 +3,168 @@ const app = require('../../app');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Quotes Routes', () => {
|
||||
let user, agent;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
describe('GET /api/quotes/random', () => {
|
||||
it('should return a random quote', async () => {
|
||||
const response = await agent.get('/api/quotes/random');
|
||||
|
||||
describe('GET /api/quotes/random', () => {
|
||||
it('should return a random quote', async () => {
|
||||
const response = await agent
|
||||
.get('/api/quotes/random');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('quote');
|
||||
expect(typeof response.body.quote).toBe('string');
|
||||
expect(response.body.quote.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('quote');
|
||||
expect(typeof response.body.quote).toBe('string');
|
||||
expect(response.body.quote.length).toBeGreaterThan(0);
|
||||
it('should return different quotes on multiple requests', async () => {
|
||||
const responses = await Promise.all([
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
]);
|
||||
|
||||
// All responses should be successful
|
||||
responses.forEach((response) => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('quote');
|
||||
expect(typeof response.body.quote).toBe('string');
|
||||
});
|
||||
|
||||
// With multiple requests, we should get at least some variety
|
||||
// (though it's possible to get the same quote multiple times due to randomness)
|
||||
const quotes = responses.map((r) => r.body.quote);
|
||||
const uniqueQuotes = new Set(quotes);
|
||||
|
||||
// We expect at least 1 unique quote, but likely more
|
||||
expect(uniqueQuotes.size).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should return valid quote structure', async () => {
|
||||
const response = await agent.get('/api/quotes/random');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Object.keys(response.body)).toEqual(['quote']);
|
||||
expect(response.body.quote).toBeTruthy();
|
||||
expect(response.body.quote.trim()).toBe(response.body.quote);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return different quotes on multiple requests', async () => {
|
||||
const responses = await Promise.all([
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random')
|
||||
]);
|
||||
describe('GET /api/quotes', () => {
|
||||
it('should return all quotes with count', async () => {
|
||||
const response = await agent.get('/api/quotes');
|
||||
|
||||
// All responses should be successful
|
||||
responses.forEach(response => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('quote');
|
||||
expect(typeof response.body.quote).toBe('string');
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('quotes');
|
||||
expect(response.body).toHaveProperty('count');
|
||||
expect(Array.isArray(response.body.quotes)).toBe(true);
|
||||
expect(typeof response.body.count).toBe('number');
|
||||
expect(response.body.quotes.length).toBe(response.body.count);
|
||||
expect(response.body.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// With multiple requests, we should get at least some variety
|
||||
// (though it's possible to get the same quote multiple times due to randomness)
|
||||
const quotes = responses.map(r => r.body.quote);
|
||||
const uniqueQuotes = new Set(quotes);
|
||||
|
||||
// We expect at least 1 unique quote, but likely more
|
||||
expect(uniqueQuotes.size).toBeGreaterThanOrEqual(1);
|
||||
it('should return valid quote array', async () => {
|
||||
const response = await agent.get('/api/quotes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// All quotes should be non-empty strings
|
||||
response.body.quotes.forEach((quote) => {
|
||||
expect(typeof quote).toBe('string');
|
||||
expect(quote.length).toBeGreaterThan(0);
|
||||
expect(quote.trim()).toBe(quote);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return consistent data across requests', async () => {
|
||||
const response1 = await agent.get('/api/quotes');
|
||||
const response2 = await agent.get('/api/quotes');
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
|
||||
// The quotes array should be the same across requests
|
||||
expect(response1.body.quotes.length).toBe(
|
||||
response2.body.quotes.length
|
||||
);
|
||||
expect(response1.body.count).toBe(response2.body.count);
|
||||
|
||||
// Verify the actual content is the same
|
||||
expect(response1.body.quotes).toEqual(response2.body.quotes);
|
||||
});
|
||||
|
||||
it('should return expected quote count', async () => {
|
||||
const response = await agent.get('/api/quotes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Based on the configuration, we expect 20 quotes, but allow for fallback quotes
|
||||
expect(response.body.count).toBeGreaterThanOrEqual(5);
|
||||
expect(response.body.quotes.length).toBe(response.body.count);
|
||||
});
|
||||
|
||||
it('should contain productivity-focused quotes', async () => {
|
||||
const response = await agent.get('/api/quotes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Look for some productivity-related keywords in the quotes
|
||||
const allQuotesText = response.body.quotes.join(' ').toLowerCase();
|
||||
|
||||
// These are common themes in productivity quotes
|
||||
const productivityKeywords = [
|
||||
'progress',
|
||||
'task',
|
||||
'goal',
|
||||
'focus',
|
||||
'accomplish',
|
||||
'success',
|
||||
'work',
|
||||
'effort',
|
||||
'achieve',
|
||||
'time',
|
||||
];
|
||||
|
||||
// At least some quotes should contain productivity-related terms
|
||||
const hasProductivityContent = productivityKeywords.some(
|
||||
(keyword) => allQuotesText.includes(keyword)
|
||||
);
|
||||
|
||||
expect(hasProductivityContent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return valid quote structure', async () => {
|
||||
const response = await agent
|
||||
.get('/api/quotes/random');
|
||||
describe('Quote randomness and consistency', () => {
|
||||
it('should have random quotes that are part of the full quote set', async () => {
|
||||
// Get all quotes first
|
||||
const allQuotesResponse = await agent.get('/api/quotes');
|
||||
const allQuotes = allQuotesResponse.body.quotes;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Object.keys(response.body)).toEqual(['quote']);
|
||||
expect(response.body.quote).toBeTruthy();
|
||||
expect(response.body.quote.trim()).toBe(response.body.quote);
|
||||
// Get several random quotes
|
||||
const randomQuoteResponses = await Promise.all([
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
]);
|
||||
|
||||
// Each random quote should be from the full set
|
||||
randomQuoteResponses.forEach((response) => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(allQuotes).toContain(response.body.quote);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/quotes', () => {
|
||||
it('should return all quotes with count', async () => {
|
||||
const response = await agent
|
||||
.get('/api/quotes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('quotes');
|
||||
expect(response.body).toHaveProperty('count');
|
||||
expect(Array.isArray(response.body.quotes)).toBe(true);
|
||||
expect(typeof response.body.count).toBe('number');
|
||||
expect(response.body.quotes.length).toBe(response.body.count);
|
||||
expect(response.body.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return valid quote array', async () => {
|
||||
const response = await agent
|
||||
.get('/api/quotes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// All quotes should be non-empty strings
|
||||
response.body.quotes.forEach(quote => {
|
||||
expect(typeof quote).toBe('string');
|
||||
expect(quote.length).toBeGreaterThan(0);
|
||||
expect(quote.trim()).toBe(quote);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return consistent data across requests', async () => {
|
||||
const response1 = await agent.get('/api/quotes');
|
||||
const response2 = await agent.get('/api/quotes');
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
|
||||
// The quotes array should be the same across requests
|
||||
expect(response1.body.quotes.length).toBe(response2.body.quotes.length);
|
||||
expect(response1.body.count).toBe(response2.body.count);
|
||||
|
||||
// Verify the actual content is the same
|
||||
expect(response1.body.quotes).toEqual(response2.body.quotes);
|
||||
});
|
||||
|
||||
it('should return expected quote count', async () => {
|
||||
const response = await agent
|
||||
.get('/api/quotes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Based on the configuration, we expect 20 quotes, but allow for fallback quotes
|
||||
expect(response.body.count).toBeGreaterThanOrEqual(5);
|
||||
expect(response.body.quotes.length).toBe(response.body.count);
|
||||
});
|
||||
|
||||
it('should contain productivity-focused quotes', async () => {
|
||||
const response = await agent
|
||||
.get('/api/quotes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Look for some productivity-related keywords in the quotes
|
||||
const allQuotesText = response.body.quotes.join(' ').toLowerCase();
|
||||
|
||||
// These are common themes in productivity quotes
|
||||
const productivityKeywords = [
|
||||
'progress', 'task', 'goal', 'focus', 'accomplish',
|
||||
'success', 'work', 'effort', 'achieve', 'time'
|
||||
];
|
||||
|
||||
// At least some quotes should contain productivity-related terms
|
||||
const hasProductivityContent = productivityKeywords.some(keyword =>
|
||||
allQuotesText.includes(keyword)
|
||||
);
|
||||
|
||||
expect(hasProductivityContent).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quote randomness and consistency', () => {
|
||||
it('should have random quotes that are part of the full quote set', async () => {
|
||||
// Get all quotes first
|
||||
const allQuotesResponse = await agent.get('/api/quotes');
|
||||
const allQuotes = allQuotesResponse.body.quotes;
|
||||
|
||||
// Get several random quotes
|
||||
const randomQuoteResponses = await Promise.all([
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random'),
|
||||
agent.get('/api/quotes/random')
|
||||
]);
|
||||
|
||||
// Each random quote should be from the full set
|
||||
randomQuoteResponses.forEach(response => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(allQuotes).toContain(response.body.quote);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,267 +4,259 @@ const { Tag, User } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Tags Routes', () => {
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tag', () => {
|
||||
it('should create a new tag', async () => {
|
||||
const tagData = {
|
||||
name: 'work'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/tag')
|
||||
.send(tagData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(tagData.name);
|
||||
expect(response.body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const tagData = {
|
||||
name: 'work'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/tag')
|
||||
.send(tagData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require tag name', async () => {
|
||||
const tagData = {};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/tag')
|
||||
.send(tagData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Tag name is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tags', () => {
|
||||
let tag1, tag2;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
tag1 = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
tag2 = await Tag.create({
|
||||
name: 'personal',
|
||||
user_id: user.id
|
||||
});
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user tags', async () => {
|
||||
const response = await agent.get('/api/tags');
|
||||
describe('POST /api/tag', () => {
|
||||
it('should create a new tag', async () => {
|
||||
const tagData = {
|
||||
name: 'work',
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
expect(response.body.map(t => t.id)).toContain(tag1.id);
|
||||
expect(response.body.map(t => t.id)).toContain(tag2.id);
|
||||
const response = await agent.post('/api/tag').send(tagData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.name).toBe(tagData.name);
|
||||
expect(response.body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const tagData = {
|
||||
name: 'work',
|
||||
};
|
||||
|
||||
const response = await request(app).post('/api/tag').send(tagData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require tag name', async () => {
|
||||
const tagData = {};
|
||||
|
||||
const response = await agent.post('/api/tag').send(tagData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Tag name is required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should order tags by name', async () => {
|
||||
const response = await agent.get('/api/tags');
|
||||
describe('GET /api/tags', () => {
|
||||
let tag1, tag2;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body[0].name).toBe('personal'); // P comes before W
|
||||
expect(response.body[1].name).toBe('work');
|
||||
beforeEach(async () => {
|
||||
tag1 = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
tag2 = await Tag.create({
|
||||
name: 'personal',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user tags', async () => {
|
||||
const response = await agent.get('/api/tags');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveLength(2);
|
||||
expect(response.body.map((t) => t.id)).toContain(tag1.id);
|
||||
expect(response.body.map((t) => t.id)).toContain(tag2.id);
|
||||
});
|
||||
|
||||
it('should order tags by name', async () => {
|
||||
const response = await agent.get('/api/tags');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body[0].name).toBe('personal'); // P comes before W
|
||||
expect(response.body[1].name).toBe('work');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/tags');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/tags');
|
||||
describe('GET /api/tag/:id', () => {
|
||||
let tag;
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
beforeEach(async () => {
|
||||
tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tag/:id', () => {
|
||||
let tag;
|
||||
it('should get tag by id', async () => {
|
||||
const response = await agent.get(`/api/tag/${tag.id}`);
|
||||
|
||||
beforeEach(async () => {
|
||||
tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(tag.id);
|
||||
expect(response.body.name).toBe(tag.name);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent.get('/api/tag/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it("should not allow access to other user's tags", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherTag = await Tag.create({
|
||||
name: 'other-tag',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/tag/${otherTag.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/tag/${tag.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should get tag by id', async () => {
|
||||
const response = await agent.get(`/api/tag/${tag.id}`);
|
||||
describe('PATCH /api/tag/:id', () => {
|
||||
let tag;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(tag.id);
|
||||
expect(response.body.name).toBe(tag.name);
|
||||
beforeEach(async () => {
|
||||
tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update tag', async () => {
|
||||
const updateData = {
|
||||
name: 'updated-work',
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/tag/${tag.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/tag/999999')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it("should not allow updating other user's tags", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherTag = await Tag.create({
|
||||
name: 'other-tag',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/tag/${otherTag.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/tag/${tag.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent.get('/api/tag/999999');
|
||||
describe('DELETE /api/tag/:id', () => {
|
||||
let tag;
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
beforeEach(async () => {
|
||||
tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete tag', async () => {
|
||||
const response = await agent.delete(`/api/tag/${tag.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Tag successfully deleted');
|
||||
|
||||
// Verify tag is deleted
|
||||
const deletedTag = await Tag.findByPk(tag.id);
|
||||
expect(deletedTag).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent.delete('/api/tag/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it("should not allow deleting other user's tags", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherTag = await Tag.create({
|
||||
name: 'other-tag',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/tag/${otherTag.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/tag/${tag.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow access to other user\'s tags', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherTag = await Tag.create({
|
||||
name: 'other-tag',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.get(`/api/tag/${otherTag.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(`/api/tag/${tag.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/tag/:id', () => {
|
||||
let tag;
|
||||
|
||||
beforeEach(async () => {
|
||||
tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should update tag', async () => {
|
||||
const updateData = {
|
||||
name: 'updated-work'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/tag/${tag.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/tag/999999')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should not allow updating other user\'s tags', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherTag = await Tag.create({
|
||||
name: 'other-tag',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/tag/${otherTag.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/tag/${tag.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/tag/:id', () => {
|
||||
let tag;
|
||||
|
||||
beforeEach(async () => {
|
||||
tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete tag', async () => {
|
||||
const response = await agent.delete(`/api/tag/${tag.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Tag successfully deleted');
|
||||
|
||||
// Verify tag is deleted
|
||||
const deletedTag = await Tag.findByPk(tag.id);
|
||||
expect(deletedTag).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent tag', async () => {
|
||||
const response = await agent.delete('/api/tag/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should not allow deleting other user\'s tags', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherTag = await Tag.create({
|
||||
name: 'other-tag',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/tag/${otherTag.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/tag/${tag.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,271 +4,260 @@ const { Task, User } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Tasks Routes', () => {
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/task', () => {
|
||||
it('should create a new task', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
note: 'Test Note',
|
||||
priority: 1,
|
||||
status: 0
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/task')
|
||||
.send(taskData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.name).toBe(taskData.name);
|
||||
expect(response.body.note).toBe(taskData.note);
|
||||
expect(response.body.priority).toBe(taskData.priority);
|
||||
expect(response.body.status).toBe(taskData.status);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/task')
|
||||
.send(taskData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require task name', async () => {
|
||||
// Mock console.error to suppress expected error log in test output
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const taskData = {
|
||||
description: 'Test Description'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/task')
|
||||
.send(taskData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
// Restore original console.error
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tasks', () => {
|
||||
let task1, task2;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
task1 = await Task.create({
|
||||
name: 'Task 1',
|
||||
description: 'Description 1',
|
||||
user_id: user.id,
|
||||
today: true
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
task2 = await Task.create({
|
||||
name: 'Task 2',
|
||||
description: 'Description 2',
|
||||
user_id: user.id,
|
||||
today: false
|
||||
});
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user tasks', async () => {
|
||||
const response = await agent.get('/api/tasks');
|
||||
describe('POST /api/task', () => {
|
||||
it('should create a new task', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
note: 'Test Note',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.tasks).toBeDefined();
|
||||
expect(response.body.tasks.length).toBe(2);
|
||||
expect(response.body.tasks.map(t => t.id)).toContain(task1.id);
|
||||
expect(response.body.tasks.map(t => t.id)).toContain(task2.id);
|
||||
const response = await agent.post('/api/task').send(taskData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.name).toBe(taskData.name);
|
||||
expect(response.body.note).toBe(taskData.note);
|
||||
expect(response.body.priority).toBe(taskData.priority);
|
||||
expect(response.body.status).toBe(taskData.status);
|
||||
expect(response.body.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/task')
|
||||
.send(taskData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require task name', async () => {
|
||||
// Mock console.error to suppress expected error log in test output
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
const taskData = {
|
||||
description: 'Test Description',
|
||||
};
|
||||
|
||||
const response = await agent.post('/api/task').send(taskData);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
// Restore original console.error
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter today tasks (returns all user tasks)', async () => {
|
||||
const response = await agent.get('/api/tasks?type=today');
|
||||
describe('GET /api/tasks', () => {
|
||||
let task1, task2;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.tasks).toBeDefined();
|
||||
expect(response.body.tasks.length).toBe(2);
|
||||
// Both tasks should be returned as "today" doesn't filter by the today field
|
||||
beforeEach(async () => {
|
||||
task1 = await Task.create({
|
||||
name: 'Task 1',
|
||||
description: 'Description 1',
|
||||
user_id: user.id,
|
||||
today: true,
|
||||
});
|
||||
|
||||
task2 = await Task.create({
|
||||
name: 'Task 2',
|
||||
description: 'Description 2',
|
||||
user_id: user.id,
|
||||
today: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all user tasks', async () => {
|
||||
const response = await agent.get('/api/tasks');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.tasks).toBeDefined();
|
||||
expect(response.body.tasks.length).toBe(2);
|
||||
expect(response.body.tasks.map((t) => t.id)).toContain(task1.id);
|
||||
expect(response.body.tasks.map((t) => t.id)).toContain(task2.id);
|
||||
});
|
||||
|
||||
it('should filter today tasks (returns all user tasks)', async () => {
|
||||
const response = await agent.get('/api/tasks?type=today');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.tasks).toBeDefined();
|
||||
expect(response.body.tasks.length).toBe(2);
|
||||
// Both tasks should be returned as "today" doesn't filter by the today field
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/tasks');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/tasks');
|
||||
// Note: No individual task GET route exists in the current API
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
describe('PATCH /api/task/:id', () => {
|
||||
let task;
|
||||
|
||||
// Note: No individual task GET route exists in the current API
|
||||
beforeEach(async () => {
|
||||
task = await Task.create({
|
||||
name: 'Test Task',
|
||||
description: 'Test Description',
|
||||
priority: 0,
|
||||
status: 0,
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/task/:id', () => {
|
||||
let task;
|
||||
it('should update task', async () => {
|
||||
const updateData = {
|
||||
name: 'Updated Task',
|
||||
note: 'Updated Note',
|
||||
priority: 2,
|
||||
status: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
task = await Task.create({
|
||||
name: 'Test Task',
|
||||
description: 'Test Description',
|
||||
priority: 0,
|
||||
status: 0,
|
||||
user_id: user.id
|
||||
});
|
||||
const response = await agent
|
||||
.patch(`/api/task/${task.id}`)
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
expect(response.body.note).toBe(updateData.note);
|
||||
expect(response.body.priority).toBe(updateData.priority);
|
||||
expect(response.body.status).toBe(updateData.status);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/task/999999')
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
});
|
||||
|
||||
it("should not allow updating other user's tasks", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherTask = await Task.create({
|
||||
name: 'Other Task',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/task/${otherTask.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/task/${task.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update task', async () => {
|
||||
const updateData = {
|
||||
name: 'Updated Task',
|
||||
note: 'Updated Note',
|
||||
priority: 2,
|
||||
status: 1
|
||||
};
|
||||
describe('DELETE /api/task/:id', () => {
|
||||
let task;
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/task/${task.id}`)
|
||||
.send(updateData);
|
||||
beforeEach(async () => {
|
||||
task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(response.body.name).toBe(updateData.name);
|
||||
expect(response.body.note).toBe(updateData.note);
|
||||
expect(response.body.priority).toBe(updateData.priority);
|
||||
expect(response.body.status).toBe(updateData.status);
|
||||
it('should delete task', async () => {
|
||||
const response = await agent.delete(`/api/task/${task.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Task successfully deleted');
|
||||
|
||||
// Verify task is deleted
|
||||
const deletedTask = await Task.findByPk(task.id);
|
||||
expect(deletedTask).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const response = await agent.delete('/api/task/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
});
|
||||
|
||||
it("should not allow deleting other user's tasks", async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const otherTask = await Task.create({
|
||||
name: 'Other Task',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/task/${otherTask.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/task/${task.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const response = await agent
|
||||
.patch('/api/task/999999')
|
||||
.send({ name: 'Updated' });
|
||||
describe('Task with tags', () => {
|
||||
it('should create task with tags', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
tags: [{ name: 'work' }, { name: 'urgent' }],
|
||||
};
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
const response = await agent.post('/api/task').send(taskData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.Tags).toBeDefined();
|
||||
expect(response.body.Tags.length).toBe(2);
|
||||
expect(response.body.Tags.map((t) => t.name)).toContain('work');
|
||||
expect(response.body.Tags.map((t) => t.name)).toContain('urgent');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow updating other user\'s tasks', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherTask = await Task.create({
|
||||
name: 'Other Task',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.patch(`/api/task/${otherTask.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.patch(`/api/task/${task.id}`)
|
||||
.send({ name: 'Updated' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/task/:id', () => {
|
||||
let task;
|
||||
|
||||
beforeEach(async () => {
|
||||
task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete task', async () => {
|
||||
const response = await agent.delete(`/api/task/${task.id}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Task successfully deleted');
|
||||
|
||||
// Verify task is deleted
|
||||
const deletedTask = await Task.findByPk(task.id);
|
||||
expect(deletedTask).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const response = await agent.delete('/api/task/999999');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
});
|
||||
|
||||
it('should not allow deleting other user\'s tasks', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const otherTask = await Task.create({
|
||||
name: 'Other Task',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
const response = await agent.delete(`/api/task/${otherTask.id}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.error).toBe('Task not found.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).delete(`/api/task/${task.id}`);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task with tags', () => {
|
||||
it('should create task with tags', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
tags: [
|
||||
{ name: 'work' },
|
||||
{ name: 'urgent' }
|
||||
]
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.post('/api/task')
|
||||
.send(taskData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.Tags).toBeDefined();
|
||||
expect(response.body.Tags.length).toBe(2);
|
||||
expect(response.body.Tags.map(t => t.name)).toContain('work');
|
||||
expect(response.body.Tags.map(t => t.name)).toContain('urgent');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,363 +3,399 @@ const telegramPoller = require('../../services/telegramPoller');
|
|||
|
||||
// Mock the HTTPS module to simulate Telegram API responses
|
||||
jest.mock('https', () => {
|
||||
const mockResponse = {
|
||||
on: jest.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
// Simulate API response with duplicate updates
|
||||
callback(JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
update_id: 1001,
|
||||
message: {
|
||||
message_id: 123,
|
||||
text: 'Buy groceries from the store',
|
||||
chat: { id: 987654321 },
|
||||
date: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
const mockResponse = {
|
||||
on: jest.fn((event, callback) => {
|
||||
if (event === 'data') {
|
||||
// Simulate API response with duplicate updates
|
||||
callback(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: [
|
||||
{
|
||||
update_id: 1001,
|
||||
message: {
|
||||
message_id: 123,
|
||||
text: 'Buy groceries from the store',
|
||||
chat: { id: 987654321 },
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
} else if (event === 'end') {
|
||||
callback();
|
||||
}
|
||||
]
|
||||
}));
|
||||
} else if (event === 'end') {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
on: jest.fn(),
|
||||
write: jest.fn(),
|
||||
end: jest.fn()
|
||||
};
|
||||
const mockRequest = {
|
||||
on: jest.fn(),
|
||||
write: jest.fn(),
|
||||
end: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
get: jest.fn((url, options, callback) => {
|
||||
callback(mockResponse);
|
||||
return mockRequest;
|
||||
}),
|
||||
request: jest.fn((url, options, callback) => {
|
||||
callback(mockResponse);
|
||||
return mockRequest;
|
||||
})
|
||||
};
|
||||
return {
|
||||
get: jest.fn((url, options, callback) => {
|
||||
callback(mockResponse);
|
||||
return mockRequest;
|
||||
}),
|
||||
request: jest.fn((url, options, callback) => {
|
||||
callback(mockResponse);
|
||||
return mockRequest;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Telegram Duplicate Message Scenario', () => {
|
||||
let testUser;
|
||||
let consoleMessages;
|
||||
let testUser;
|
||||
let consoleMessages;
|
||||
|
||||
beforeAll(async () => {
|
||||
await sequelize.sync({ force: true });
|
||||
|
||||
// Capture console logs
|
||||
consoleMessages = [];
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = (...args) => {
|
||||
consoleMessages.push(args.join(' '));
|
||||
originalConsoleLog(...args);
|
||||
};
|
||||
});
|
||||
beforeAll(async () => {
|
||||
await sequelize.sync({ force: true });
|
||||
|
||||
beforeEach(async () => {
|
||||
consoleMessages = [];
|
||||
|
||||
// Create test user with Telegram configuration
|
||||
testUser = await User.create({
|
||||
email: 'telegram-user@example.com',
|
||||
password_digest: 'hashedpassword',
|
||||
telegram_bot_token: 'real-bot-token-456',
|
||||
telegram_chat_id: '987654321'
|
||||
// Capture console logs
|
||||
consoleMessages = [];
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = (...args) => {
|
||||
consoleMessages.push(args.join(' '));
|
||||
originalConsoleLog(...args);
|
||||
};
|
||||
});
|
||||
|
||||
// Clear inbox
|
||||
await InboxItem.destroy({ where: {} });
|
||||
|
||||
// Reset poller
|
||||
telegramPoller.stopPolling();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
consoleMessages = [];
|
||||
|
||||
afterEach(async () => {
|
||||
telegramPoller.stopPolling();
|
||||
await User.destroy({ where: {} });
|
||||
await InboxItem.destroy({ where: {} });
|
||||
});
|
||||
// Create test user with Telegram configuration
|
||||
testUser = await User.create({
|
||||
email: 'telegram-user@example.com',
|
||||
password_digest: 'hashedpassword',
|
||||
telegram_bot_token: 'real-bot-token-456',
|
||||
telegram_chat_id: '987654321',
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await sequelize.close();
|
||||
});
|
||||
// Clear inbox
|
||||
await InboxItem.destroy({ where: {} });
|
||||
|
||||
describe('Real-world Duplicate Scenarios', () => {
|
||||
test('should prevent duplicates when same message is processed twice due to network issues', async () => {
|
||||
const messageContent = 'Buy groceries from the store';
|
||||
const messageId = 123;
|
||||
const updateId = 1001;
|
||||
|
||||
// Simulate first message processing
|
||||
const inboxItem1 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: messageId }
|
||||
});
|
||||
|
||||
// Wait a moment (simulating network delay)
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Simulate duplicate processing attempt (same message, different processing cycle)
|
||||
const recentCutoff = new Date(Date.now() - 30000);
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: messageContent,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: recentCutoff
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Should find the existing item
|
||||
expect(existingItem).toBeTruthy();
|
||||
expect(existingItem.id).toBe(inboxItem1.id);
|
||||
|
||||
// Verify only one item exists
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id }
|
||||
});
|
||||
expect(allItems).toHaveLength(1);
|
||||
// Reset poller
|
||||
telegramPoller.stopPolling();
|
||||
});
|
||||
|
||||
test('should handle rapid consecutive messages without creating duplicates', async () => {
|
||||
const messages = [
|
||||
{ content: 'First message', messageId: 201, updateId: 2001 },
|
||||
{ content: 'Second message', messageId: 202, updateId: 2002 },
|
||||
{ content: 'First message', messageId: 203, updateId: 2003 }, // Duplicate content
|
||||
{ content: 'Third message', messageId: 204, updateId: 2004 }
|
||||
];
|
||||
afterEach(async () => {
|
||||
telegramPoller.stopPolling();
|
||||
await User.destroy({ where: {} });
|
||||
await InboxItem.destroy({ where: {} });
|
||||
});
|
||||
|
||||
// Process all messages rapidly
|
||||
const createdItems = [];
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
// Check for existing item first (simulating the duplicate prevention logic)
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: msg.content,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: new Date(Date.now() - 30000)
|
||||
}
|
||||
}
|
||||
});
|
||||
afterAll(async () => {
|
||||
await sequelize.close();
|
||||
});
|
||||
|
||||
if (existingItem) {
|
||||
console.log(`Duplicate detected: "${msg.content}"`);
|
||||
createdItems.push(existingItem);
|
||||
} else {
|
||||
const newItem = await InboxItem.create({
|
||||
content: msg.content,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: msg.messageId }
|
||||
describe('Real-world Duplicate Scenarios', () => {
|
||||
test('should prevent duplicates when same message is processed twice due to network issues', async () => {
|
||||
const messageContent = 'Buy groceries from the store';
|
||||
const messageId = 123;
|
||||
const updateId = 1001;
|
||||
|
||||
// Simulate first message processing
|
||||
const inboxItem1 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: messageId },
|
||||
});
|
||||
createdItems.push(newItem);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have 3 unique items (first, second, third) - duplicate "First message" should be prevented
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id }
|
||||
});
|
||||
|
||||
expect(allItems).toHaveLength(3);
|
||||
|
||||
const contentCounts = allItems.reduce((acc, item) => {
|
||||
acc[item.content] = (acc[item.content] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
expect(contentCounts['First message']).toBe(1);
|
||||
expect(contentCounts['Second message']).toBe(1);
|
||||
expect(contentCounts['Third message']).toBe(1);
|
||||
});
|
||||
// Wait a moment (simulating network delay)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
test('should track update IDs correctly to prevent reprocessing', async () => {
|
||||
// Simulate the internal update tracking logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
const updates = [
|
||||
{ update_id: 3001, message: { text: 'Message 1', message_id: 301, chat: { id: 987654321 } } },
|
||||
{ update_id: 3002, message: { text: 'Message 2', message_id: 302, chat: { id: 987654321 } } },
|
||||
{ update_id: 3001, message: { text: 'Message 1', message_id: 301, chat: { id: 987654321 } } }, // Duplicate update
|
||||
{ update_id: 3003, message: { text: 'Message 3', message_id: 303, chat: { id: 987654321 } } }
|
||||
];
|
||||
// Simulate duplicate processing attempt (same message, different processing cycle)
|
||||
const recentCutoff = new Date(Date.now() - 30000);
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: messageContent,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: recentCutoff,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const processedCount = { count: 0 };
|
||||
|
||||
for (const update of updates) {
|
||||
const updateKey = `${testUser.id}-${update.update_id}`;
|
||||
|
||||
if (!processedUpdates.has(updateKey)) {
|
||||
// Simulate processing the update
|
||||
processedUpdates.add(updateKey);
|
||||
processedCount.count++;
|
||||
|
||||
// Simulate creating inbox item
|
||||
await InboxItem.create({
|
||||
content: update.message.text,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: {
|
||||
telegram_message_id: update.message.message_id,
|
||||
update_id: update.update_id
|
||||
// Should find the existing item
|
||||
expect(existingItem).toBeTruthy();
|
||||
expect(existingItem.id).toBe(inboxItem1.id);
|
||||
|
||||
// Verify only one item exists
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id },
|
||||
});
|
||||
expect(allItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should handle rapid consecutive messages without creating duplicates', async () => {
|
||||
const messages = [
|
||||
{ content: 'First message', messageId: 201, updateId: 2001 },
|
||||
{ content: 'Second message', messageId: 202, updateId: 2002 },
|
||||
{ content: 'First message', messageId: 203, updateId: 2003 }, // Duplicate content
|
||||
{ content: 'Third message', messageId: 204, updateId: 2004 },
|
||||
];
|
||||
|
||||
// Process all messages rapidly
|
||||
const createdItems = [];
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
// Check for existing item first (simulating the duplicate prevention logic)
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: msg.content,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: new Date(
|
||||
Date.now() - 30000
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingItem) {
|
||||
console.log(`Duplicate detected: "${msg.content}"`);
|
||||
createdItems.push(existingItem);
|
||||
} else {
|
||||
const newItem = await InboxItem.create({
|
||||
content: msg.content,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: msg.messageId },
|
||||
});
|
||||
createdItems.push(newItem);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`Skipping already processed update: ${update.update_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have processed 3 unique updates (3001, 3002, 3003)
|
||||
expect(processedCount.count).toBe(3);
|
||||
expect(processedUpdates.size).toBe(3);
|
||||
|
||||
// Verify inbox items
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id }
|
||||
});
|
||||
expect(allItems).toHaveLength(3);
|
||||
// Should have 3 unique items (first, second, third) - duplicate "First message" should be prevented
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id },
|
||||
});
|
||||
|
||||
expect(allItems).toHaveLength(3);
|
||||
|
||||
const contentCounts = allItems.reduce((acc, item) => {
|
||||
acc[item.content] = (acc[item.content] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
expect(contentCounts['First message']).toBe(1);
|
||||
expect(contentCounts['Second message']).toBe(1);
|
||||
expect(contentCounts['Third message']).toBe(1);
|
||||
});
|
||||
|
||||
test('should track update IDs correctly to prevent reprocessing', async () => {
|
||||
// Simulate the internal update tracking logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
const updates = [
|
||||
{
|
||||
update_id: 3001,
|
||||
message: {
|
||||
text: 'Message 1',
|
||||
message_id: 301,
|
||||
chat: { id: 987654321 },
|
||||
},
|
||||
},
|
||||
{
|
||||
update_id: 3002,
|
||||
message: {
|
||||
text: 'Message 2',
|
||||
message_id: 302,
|
||||
chat: { id: 987654321 },
|
||||
},
|
||||
},
|
||||
{
|
||||
update_id: 3001,
|
||||
message: {
|
||||
text: 'Message 1',
|
||||
message_id: 301,
|
||||
chat: { id: 987654321 },
|
||||
},
|
||||
}, // Duplicate update
|
||||
{
|
||||
update_id: 3003,
|
||||
message: {
|
||||
text: 'Message 3',
|
||||
message_id: 303,
|
||||
chat: { id: 987654321 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const processedCount = { count: 0 };
|
||||
|
||||
for (const update of updates) {
|
||||
const updateKey = `${testUser.id}-${update.update_id}`;
|
||||
|
||||
if (!processedUpdates.has(updateKey)) {
|
||||
// Simulate processing the update
|
||||
processedUpdates.add(updateKey);
|
||||
processedCount.count++;
|
||||
|
||||
// Simulate creating inbox item
|
||||
await InboxItem.create({
|
||||
content: update.message.text,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: {
|
||||
telegram_message_id: update.message.message_id,
|
||||
update_id: update.update_id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
`Skipping already processed update: ${update.update_id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have processed 3 unique updates (3001, 3002, 3003)
|
||||
expect(processedCount.count).toBe(3);
|
||||
expect(processedUpdates.size).toBe(3);
|
||||
|
||||
// Verify inbox items
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id },
|
||||
});
|
||||
expect(allItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('should handle poller restart without creating duplicates', async () => {
|
||||
// Create initial inbox item
|
||||
const initialItem = await InboxItem.create({
|
||||
content: 'Message before restart',
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 401, update_id: 4001 },
|
||||
});
|
||||
|
||||
// Add user to poller
|
||||
await telegramPoller.addUser(testUser);
|
||||
|
||||
// Simulate getting status
|
||||
let status = telegramPoller.getStatus();
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.usersCount).toBe(1);
|
||||
|
||||
// Stop poller (simulating restart)
|
||||
telegramPoller.stopPolling();
|
||||
status = telegramPoller.getStatus();
|
||||
expect(status.running).toBe(false);
|
||||
|
||||
// Start again
|
||||
await telegramPoller.addUser(testUser);
|
||||
status = telegramPoller.getStatus();
|
||||
expect(status.running).toBe(true);
|
||||
|
||||
// The poller should maintain its state correctly
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id },
|
||||
});
|
||||
expect(allItems).toHaveLength(1);
|
||||
expect(allItems[0].id).toBe(initialItem.id);
|
||||
});
|
||||
|
||||
test('should cleanup old processed updates to prevent memory leaks', async () => {
|
||||
// Test the memory management logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
// Add many processed updates
|
||||
for (let i = 1; i <= 1200; i++) {
|
||||
processedUpdates.add(`${testUser.id}-${i}`);
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1200);
|
||||
|
||||
// Simulate the cleanup logic (keeping only 1000 most recent)
|
||||
if (processedUpdates.size > 1000) {
|
||||
const allEntries = Array.from(processedUpdates);
|
||||
const oldestEntries = allEntries.slice(0, 200); // Remove oldest 200
|
||||
oldestEntries.forEach((entry) =>
|
||||
processedUpdates.delete(entry)
|
||||
);
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1000);
|
||||
|
||||
// Verify oldest entries are removed
|
||||
expect(processedUpdates.has(`${testUser.id}-1`)).toBe(false);
|
||||
expect(processedUpdates.has(`${testUser.id}-200`)).toBe(false);
|
||||
|
||||
// Verify newest entries are kept
|
||||
expect(processedUpdates.has(`${testUser.id}-1200`)).toBe(true);
|
||||
expect(processedUpdates.has(`${testUser.id}-1000`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle poller restart without creating duplicates', async () => {
|
||||
// Create initial inbox item
|
||||
const initialItem = await InboxItem.create({
|
||||
content: 'Message before restart',
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 401, update_id: 4001 }
|
||||
});
|
||||
describe('Edge Cases', () => {
|
||||
test('should handle identical messages from different Telegram message IDs', async () => {
|
||||
const messageContent = 'Identical content';
|
||||
|
||||
// Add user to poller
|
||||
await telegramPoller.addUser(testUser);
|
||||
|
||||
// Simulate getting status
|
||||
let status = telegramPoller.getStatus();
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.usersCount).toBe(1);
|
||||
// Create first message
|
||||
const item1 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 501 },
|
||||
});
|
||||
|
||||
// Stop poller (simulating restart)
|
||||
telegramPoller.stopPolling();
|
||||
status = telegramPoller.getStatus();
|
||||
expect(status.running).toBe(false);
|
||||
// Wait a moment
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Start again
|
||||
await telegramPoller.addUser(testUser);
|
||||
status = telegramPoller.getStatus();
|
||||
expect(status.running).toBe(true);
|
||||
// Try to create with same content but different message ID
|
||||
// This should be prevented by the content-based duplicate check
|
||||
const recentCutoff = new Date(Date.now() - 30000);
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: messageContent,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: recentCutoff,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The poller should maintain its state correctly
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id }
|
||||
});
|
||||
expect(allItems).toHaveLength(1);
|
||||
expect(allItems[0].id).toBe(initialItem.id);
|
||||
expect(existingItem).toBeTruthy();
|
||||
expect(existingItem.id).toBe(item1.id);
|
||||
});
|
||||
|
||||
test('should allow same content after time window expires', async () => {
|
||||
const messageContent = 'Time-based test message';
|
||||
|
||||
// Create first item with old timestamp
|
||||
const oldTimestamp = new Date(Date.now() - 35000); // 35 seconds ago
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
created_at: oldTimestamp,
|
||||
updated_at: oldTimestamp,
|
||||
metadata: { telegram_message_id: 601 },
|
||||
});
|
||||
|
||||
// Now try to create new item with same content
|
||||
const newItem = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 602 },
|
||||
});
|
||||
|
||||
// Should be allowed since the old one is outside the 30-second window
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id },
|
||||
});
|
||||
expect(allItems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should cleanup old processed updates to prevent memory leaks', async () => {
|
||||
// Test the memory management logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
// Add many processed updates
|
||||
for (let i = 1; i <= 1200; i++) {
|
||||
processedUpdates.add(`${testUser.id}-${i}`);
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1200);
|
||||
|
||||
// Simulate the cleanup logic (keeping only 1000 most recent)
|
||||
if (processedUpdates.size > 1000) {
|
||||
const allEntries = Array.from(processedUpdates);
|
||||
const oldestEntries = allEntries.slice(0, 200); // Remove oldest 200
|
||||
oldestEntries.forEach(entry => processedUpdates.delete(entry));
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1000);
|
||||
|
||||
// Verify oldest entries are removed
|
||||
expect(processedUpdates.has(`${testUser.id}-1`)).toBe(false);
|
||||
expect(processedUpdates.has(`${testUser.id}-200`)).toBe(false);
|
||||
|
||||
// Verify newest entries are kept
|
||||
expect(processedUpdates.has(`${testUser.id}-1200`)).toBe(true);
|
||||
expect(processedUpdates.has(`${testUser.id}-1000`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('should handle identical messages from different Telegram message IDs', async () => {
|
||||
const messageContent = 'Identical content';
|
||||
|
||||
// Create first message
|
||||
const item1 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 501 }
|
||||
});
|
||||
|
||||
// Wait a moment
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Try to create with same content but different message ID
|
||||
// This should be prevented by the content-based duplicate check
|
||||
const recentCutoff = new Date(Date.now() - 30000);
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: messageContent,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: recentCutoff
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(existingItem).toBeTruthy();
|
||||
expect(existingItem.id).toBe(item1.id);
|
||||
});
|
||||
|
||||
test('should allow same content after time window expires', async () => {
|
||||
const messageContent = 'Time-based test message';
|
||||
|
||||
// Create first item with old timestamp
|
||||
const oldTimestamp = new Date(Date.now() - 35000); // 35 seconds ago
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
created_at: oldTimestamp,
|
||||
updated_at: oldTimestamp,
|
||||
metadata: { telegram_message_id: 601 }
|
||||
});
|
||||
|
||||
// Now try to create new item with same content
|
||||
const newItem = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 602 }
|
||||
});
|
||||
|
||||
// Should be allowed since the old one is outside the 30-second window
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id }
|
||||
});
|
||||
expect(allItems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,322 +4,339 @@ const { User, InboxItem, sequelize } = require('../../models');
|
|||
const telegramPoller = require('../../services/telegramPoller');
|
||||
|
||||
describe('Telegram Duplicate Prevention Integration Tests', () => {
|
||||
let testUser;
|
||||
let originalConsoleLog;
|
||||
let logMessages;
|
||||
let testUser;
|
||||
let originalConsoleLog;
|
||||
let logMessages;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Capture console.log for verification
|
||||
originalConsoleLog = console.log;
|
||||
logMessages = [];
|
||||
console.log = (...args) => {
|
||||
logMessages.push(args.join(' '));
|
||||
originalConsoleLog(...args);
|
||||
};
|
||||
beforeAll(async () => {
|
||||
// Capture console.log for verification
|
||||
originalConsoleLog = console.log;
|
||||
logMessages = [];
|
||||
console.log = (...args) => {
|
||||
logMessages.push(args.join(' '));
|
||||
originalConsoleLog(...args);
|
||||
};
|
||||
|
||||
await sequelize.sync({ force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
logMessages = [];
|
||||
|
||||
// Create test user
|
||||
testUser = await User.create({
|
||||
email: 'test-telegram@example.com',
|
||||
password_digest: 'hashedpassword',
|
||||
telegram_bot_token: 'test-bot-token-123',
|
||||
telegram_chat_id: '987654321'
|
||||
await sequelize.sync({ force: true });
|
||||
});
|
||||
|
||||
// Clear any existing inbox items
|
||||
await InboxItem.destroy({ where: {} });
|
||||
|
||||
// Stop and reset poller
|
||||
telegramPoller.stopPolling();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
logMessages = [];
|
||||
|
||||
afterEach(async () => {
|
||||
telegramPoller.stopPolling();
|
||||
await User.destroy({ where: {} });
|
||||
await InboxItem.destroy({ where: {} });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
console.log = originalConsoleLog;
|
||||
await sequelize.close();
|
||||
});
|
||||
|
||||
describe('Database-level Duplicate Prevention', () => {
|
||||
test('should prevent duplicate inbox items with same content within 30 seconds', async () => {
|
||||
const messageContent = 'Test duplicate message';
|
||||
|
||||
// Create first inbox item
|
||||
const item1 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 123 }
|
||||
});
|
||||
|
||||
// Wait a moment
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Try to create duplicate item (should be prevented)
|
||||
const duplicateCheck = await InboxItem.findOne({
|
||||
where: {
|
||||
content: messageContent,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: new Date(Date.now() - 30000)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(duplicateCheck).toBeTruthy();
|
||||
expect(duplicateCheck.id).toBe(item1.id);
|
||||
|
||||
// Verify only one item exists
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id }
|
||||
});
|
||||
expect(allItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should allow duplicate content after 30 seconds', async () => {
|
||||
const messageContent = 'Test time-based duplicate';
|
||||
|
||||
// Create first item with backdated timestamp
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
created_at: new Date(Date.now() - 35000), // 35 seconds ago
|
||||
metadata: { telegram_message_id: 124 }
|
||||
});
|
||||
|
||||
// Create second item (should be allowed)
|
||||
const item2 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 125 }
|
||||
});
|
||||
|
||||
// Verify both items exist
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id }
|
||||
});
|
||||
expect(allItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should allow same content for different users', async () => {
|
||||
// Create second user
|
||||
const testUser2 = await User.create({
|
||||
email: 'test2-telegram@example.com',
|
||||
password_digest: 'hashedpassword',
|
||||
telegram_bot_token: 'test-bot-token-456',
|
||||
telegram_chat_id: '123456789'
|
||||
});
|
||||
|
||||
const messageContent = 'Shared message content';
|
||||
|
||||
// Create item for first user
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 126 }
|
||||
});
|
||||
|
||||
// Create item for second user (should be allowed)
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser2.id,
|
||||
metadata: { telegram_message_id: 127 }
|
||||
});
|
||||
|
||||
// Verify both items exist
|
||||
const allItems = await InboxItem.findAll();
|
||||
expect(allItems).toHaveLength(2);
|
||||
|
||||
const user1Items = allItems.filter(item => item.user_id === testUser.id);
|
||||
const user2Items = allItems.filter(item => item.user_id === testUser2.id);
|
||||
|
||||
expect(user1Items).toHaveLength(1);
|
||||
expect(user2Items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Poller State Management', () => {
|
||||
test('should add and remove users correctly', async () => {
|
||||
const initialStatus = telegramPoller.getStatus();
|
||||
expect(initialStatus.usersCount).toBe(0);
|
||||
expect(initialStatus.running).toBe(false);
|
||||
|
||||
// Add user
|
||||
const addResult = await telegramPoller.addUser(testUser);
|
||||
expect(addResult).toBe(true);
|
||||
|
||||
const statusAfterAdd = telegramPoller.getStatus();
|
||||
expect(statusAfterAdd.usersCount).toBe(1);
|
||||
expect(statusAfterAdd.running).toBe(true);
|
||||
|
||||
// Remove user
|
||||
const removeResult = telegramPoller.removeUser(testUser.id);
|
||||
expect(removeResult).toBe(true);
|
||||
|
||||
const statusAfterRemove = telegramPoller.getStatus();
|
||||
expect(statusAfterRemove.usersCount).toBe(0);
|
||||
expect(statusAfterRemove.running).toBe(false);
|
||||
});
|
||||
|
||||
test('should not add user without telegram token', async () => {
|
||||
const userWithoutToken = await User.create({
|
||||
email: 'no-token@example.com',
|
||||
password_digest: 'hashedpassword'
|
||||
// No telegram_bot_token
|
||||
});
|
||||
|
||||
const addResult = await telegramPoller.addUser(userWithoutToken);
|
||||
expect(addResult).toBe(false);
|
||||
|
||||
const status = telegramPoller.getStatus();
|
||||
expect(status.usersCount).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle adding same user multiple times', async () => {
|
||||
// Add user first time
|
||||
await telegramPoller.addUser(testUser);
|
||||
const status1 = telegramPoller.getStatus();
|
||||
expect(status1.usersCount).toBe(1);
|
||||
|
||||
// Add same user again
|
||||
await telegramPoller.addUser(testUser);
|
||||
const status2 = telegramPoller.getStatus();
|
||||
expect(status2.usersCount).toBe(1); // Should still be 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Processing Logic', () => {
|
||||
test('should handle updates with proper ID tracking', async () => {
|
||||
await telegramPoller.addUser(testUser);
|
||||
|
||||
// Simulate updates (this tests the internal logic without actual HTTP calls)
|
||||
const mockUpdates = [
|
||||
{
|
||||
update_id: 1001,
|
||||
message: {
|
||||
message_id: 501,
|
||||
text: 'First message',
|
||||
chat: { id: 987654321 }
|
||||
}
|
||||
},
|
||||
{
|
||||
update_id: 1002,
|
||||
message: {
|
||||
message_id: 502,
|
||||
text: 'Second message',
|
||||
chat: { id: 987654321 }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Test highest update ID calculation
|
||||
const highestId = telegramPoller._getHighestUpdateId(mockUpdates);
|
||||
expect(highestId).toBe(1002);
|
||||
|
||||
// Test update key generation (simulating internal logic)
|
||||
const updateKeys = mockUpdates.map(update => `${testUser.id}-${update.update_id}`);
|
||||
expect(updateKeys).toEqual([`${testUser.id}-1001`, `${testUser.id}-1002`]);
|
||||
});
|
||||
|
||||
test('should properly track processed updates', async () => {
|
||||
// Test the Set-based tracking logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
// Add some processed updates
|
||||
processedUpdates.add('1-1001');
|
||||
processedUpdates.add('1-1002');
|
||||
|
||||
// Test filtering logic
|
||||
const newUpdates = [
|
||||
{ update_id: 1001 }, // Should be filtered out
|
||||
{ update_id: 1002 }, // Should be filtered out
|
||||
{ update_id: 1003 } // Should remain
|
||||
].filter(update => {
|
||||
const updateKey = `1-${update.update_id}`;
|
||||
return !processedUpdates.has(updateKey);
|
||||
});
|
||||
|
||||
expect(newUpdates).toHaveLength(1);
|
||||
expect(newUpdates[0].update_id).toBe(1003);
|
||||
});
|
||||
|
||||
test('should handle memory management for processed updates', async () => {
|
||||
// Simulate the cleanup logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
// Add many updates (more than the 1000 limit)
|
||||
for (let i = 1; i <= 1100; i++) {
|
||||
processedUpdates.add(`1-${i}`);
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1100);
|
||||
|
||||
// Simulate cleanup (remove oldest 100)
|
||||
if (processedUpdates.size > 1000) {
|
||||
const oldestEntries = Array.from(processedUpdates).slice(0, 100);
|
||||
oldestEntries.forEach(entry => processedUpdates.delete(entry));
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1000);
|
||||
expect(processedUpdates.has('1-1')).toBe(false); // Oldest should be removed
|
||||
expect(processedUpdates.has('1-1100')).toBe(true); // Newest should remain
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('should handle database errors gracefully', async () => {
|
||||
// Mock InboxItem.create to throw an error
|
||||
const originalCreate = InboxItem.create;
|
||||
InboxItem.create = jest.fn().mockRejectedValue(new Error('Database error'));
|
||||
|
||||
try {
|
||||
await InboxItem.create({
|
||||
content: 'Test error handling',
|
||||
source: 'telegram',
|
||||
user_id: testUser.id
|
||||
// Create test user
|
||||
testUser = await User.create({
|
||||
email: 'test-telegram@example.com',
|
||||
password_digest: 'hashedpassword',
|
||||
telegram_bot_token: 'test-bot-token-123',
|
||||
telegram_chat_id: '987654321',
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Database error');
|
||||
}
|
||||
|
||||
// Restore original function
|
||||
InboxItem.create = originalCreate;
|
||||
|
||||
// Clear any existing inbox items
|
||||
await InboxItem.destroy({ where: {} });
|
||||
|
||||
// Stop and reset poller
|
||||
telegramPoller.stopPolling();
|
||||
});
|
||||
|
||||
test('should handle invalid user data', async () => {
|
||||
const invalidUser = null;
|
||||
const addResult = await telegramPoller.addUser(invalidUser);
|
||||
expect(addResult).toBe(false);
|
||||
afterEach(async () => {
|
||||
telegramPoller.stopPolling();
|
||||
await User.destroy({ where: {} });
|
||||
await InboxItem.destroy({ where: {} });
|
||||
});
|
||||
|
||||
test('should handle missing message properties', async () => {
|
||||
// Test the processing logic with incomplete message data
|
||||
const incompleteUpdate = {
|
||||
update_id: 2001,
|
||||
message: {
|
||||
// Missing text and other properties
|
||||
message_id: 601,
|
||||
chat: { id: 987654321 }
|
||||
}
|
||||
};
|
||||
|
||||
// The actual processing would skip this message due to missing text
|
||||
const hasText = incompleteUpdate.message && incompleteUpdate.message.text;
|
||||
expect(hasText).toBeFalsy();
|
||||
afterAll(async () => {
|
||||
console.log = originalConsoleLog;
|
||||
await sequelize.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Database-level Duplicate Prevention', () => {
|
||||
test('should prevent duplicate inbox items with same content within 30 seconds', async () => {
|
||||
const messageContent = 'Test duplicate message';
|
||||
|
||||
// Create first inbox item
|
||||
const item1 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 123 },
|
||||
});
|
||||
|
||||
// Wait a moment
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Try to create duplicate item (should be prevented)
|
||||
const duplicateCheck = await InboxItem.findOne({
|
||||
where: {
|
||||
content: messageContent,
|
||||
user_id: testUser.id,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[require('sequelize').Op.gte]: new Date(
|
||||
Date.now() - 30000
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(duplicateCheck).toBeTruthy();
|
||||
expect(duplicateCheck.id).toBe(item1.id);
|
||||
|
||||
// Verify only one item exists
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id },
|
||||
});
|
||||
expect(allItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should allow duplicate content after 30 seconds', async () => {
|
||||
const messageContent = 'Test time-based duplicate';
|
||||
|
||||
// Create first item with backdated timestamp
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
created_at: new Date(Date.now() - 35000), // 35 seconds ago
|
||||
metadata: { telegram_message_id: 124 },
|
||||
});
|
||||
|
||||
// Create second item (should be allowed)
|
||||
const item2 = await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 125 },
|
||||
});
|
||||
|
||||
// Verify both items exist
|
||||
const allItems = await InboxItem.findAll({
|
||||
where: { user_id: testUser.id },
|
||||
});
|
||||
expect(allItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should allow same content for different users', async () => {
|
||||
// Create second user
|
||||
const testUser2 = await User.create({
|
||||
email: 'test2-telegram@example.com',
|
||||
password_digest: 'hashedpassword',
|
||||
telegram_bot_token: 'test-bot-token-456',
|
||||
telegram_chat_id: '123456789',
|
||||
});
|
||||
|
||||
const messageContent = 'Shared message content';
|
||||
|
||||
// Create item for first user
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
metadata: { telegram_message_id: 126 },
|
||||
});
|
||||
|
||||
// Create item for second user (should be allowed)
|
||||
await InboxItem.create({
|
||||
content: messageContent,
|
||||
source: 'telegram',
|
||||
user_id: testUser2.id,
|
||||
metadata: { telegram_message_id: 127 },
|
||||
});
|
||||
|
||||
// Verify both items exist
|
||||
const allItems = await InboxItem.findAll();
|
||||
expect(allItems).toHaveLength(2);
|
||||
|
||||
const user1Items = allItems.filter(
|
||||
(item) => item.user_id === testUser.id
|
||||
);
|
||||
const user2Items = allItems.filter(
|
||||
(item) => item.user_id === testUser2.id
|
||||
);
|
||||
|
||||
expect(user1Items).toHaveLength(1);
|
||||
expect(user2Items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Poller State Management', () => {
|
||||
test('should add and remove users correctly', async () => {
|
||||
const initialStatus = telegramPoller.getStatus();
|
||||
expect(initialStatus.usersCount).toBe(0);
|
||||
expect(initialStatus.running).toBe(false);
|
||||
|
||||
// Add user
|
||||
const addResult = await telegramPoller.addUser(testUser);
|
||||
expect(addResult).toBe(true);
|
||||
|
||||
const statusAfterAdd = telegramPoller.getStatus();
|
||||
expect(statusAfterAdd.usersCount).toBe(1);
|
||||
expect(statusAfterAdd.running).toBe(true);
|
||||
|
||||
// Remove user
|
||||
const removeResult = telegramPoller.removeUser(testUser.id);
|
||||
expect(removeResult).toBe(true);
|
||||
|
||||
const statusAfterRemove = telegramPoller.getStatus();
|
||||
expect(statusAfterRemove.usersCount).toBe(0);
|
||||
expect(statusAfterRemove.running).toBe(false);
|
||||
});
|
||||
|
||||
test('should not add user without telegram token', async () => {
|
||||
const userWithoutToken = await User.create({
|
||||
email: 'no-token@example.com',
|
||||
password_digest: 'hashedpassword',
|
||||
// No telegram_bot_token
|
||||
});
|
||||
|
||||
const addResult = await telegramPoller.addUser(userWithoutToken);
|
||||
expect(addResult).toBe(false);
|
||||
|
||||
const status = telegramPoller.getStatus();
|
||||
expect(status.usersCount).toBe(0);
|
||||
});
|
||||
|
||||
test('should handle adding same user multiple times', async () => {
|
||||
// Add user first time
|
||||
await telegramPoller.addUser(testUser);
|
||||
const status1 = telegramPoller.getStatus();
|
||||
expect(status1.usersCount).toBe(1);
|
||||
|
||||
// Add same user again
|
||||
await telegramPoller.addUser(testUser);
|
||||
const status2 = telegramPoller.getStatus();
|
||||
expect(status2.usersCount).toBe(1); // Should still be 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Processing Logic', () => {
|
||||
test('should handle updates with proper ID tracking', async () => {
|
||||
await telegramPoller.addUser(testUser);
|
||||
|
||||
// Simulate updates (this tests the internal logic without actual HTTP calls)
|
||||
const mockUpdates = [
|
||||
{
|
||||
update_id: 1001,
|
||||
message: {
|
||||
message_id: 501,
|
||||
text: 'First message',
|
||||
chat: { id: 987654321 },
|
||||
},
|
||||
},
|
||||
{
|
||||
update_id: 1002,
|
||||
message: {
|
||||
message_id: 502,
|
||||
text: 'Second message',
|
||||
chat: { id: 987654321 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Test highest update ID calculation
|
||||
const highestId = telegramPoller._getHighestUpdateId(mockUpdates);
|
||||
expect(highestId).toBe(1002);
|
||||
|
||||
// Test update key generation (simulating internal logic)
|
||||
const updateKeys = mockUpdates.map(
|
||||
(update) => `${testUser.id}-${update.update_id}`
|
||||
);
|
||||
expect(updateKeys).toEqual([
|
||||
`${testUser.id}-1001`,
|
||||
`${testUser.id}-1002`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('should properly track processed updates', async () => {
|
||||
// Test the Set-based tracking logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
// Add some processed updates
|
||||
processedUpdates.add('1-1001');
|
||||
processedUpdates.add('1-1002');
|
||||
|
||||
// Test filtering logic
|
||||
const newUpdates = [
|
||||
{ update_id: 1001 }, // Should be filtered out
|
||||
{ update_id: 1002 }, // Should be filtered out
|
||||
{ update_id: 1003 }, // Should remain
|
||||
].filter((update) => {
|
||||
const updateKey = `1-${update.update_id}`;
|
||||
return !processedUpdates.has(updateKey);
|
||||
});
|
||||
|
||||
expect(newUpdates).toHaveLength(1);
|
||||
expect(newUpdates[0].update_id).toBe(1003);
|
||||
});
|
||||
|
||||
test('should handle memory management for processed updates', async () => {
|
||||
// Simulate the cleanup logic
|
||||
const processedUpdates = new Set();
|
||||
|
||||
// Add many updates (more than the 1000 limit)
|
||||
for (let i = 1; i <= 1100; i++) {
|
||||
processedUpdates.add(`1-${i}`);
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1100);
|
||||
|
||||
// Simulate cleanup (remove oldest 100)
|
||||
if (processedUpdates.size > 1000) {
|
||||
const oldestEntries = Array.from(processedUpdates).slice(
|
||||
0,
|
||||
100
|
||||
);
|
||||
oldestEntries.forEach((entry) =>
|
||||
processedUpdates.delete(entry)
|
||||
);
|
||||
}
|
||||
|
||||
expect(processedUpdates.size).toBe(1000);
|
||||
expect(processedUpdates.has('1-1')).toBe(false); // Oldest should be removed
|
||||
expect(processedUpdates.has('1-1100')).toBe(true); // Newest should remain
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('should handle database errors gracefully', async () => {
|
||||
// Mock InboxItem.create to throw an error
|
||||
const originalCreate = InboxItem.create;
|
||||
InboxItem.create = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await expect(
|
||||
InboxItem.create({
|
||||
content: 'Test error handling',
|
||||
source: 'telegram',
|
||||
user_id: testUser.id,
|
||||
})
|
||||
).rejects.toThrow('Database error');
|
||||
|
||||
// Restore original function
|
||||
InboxItem.create = originalCreate;
|
||||
});
|
||||
|
||||
test('should handle invalid user data', async () => {
|
||||
const invalidUser = null;
|
||||
const addResult = await telegramPoller.addUser(invalidUser);
|
||||
expect(addResult).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle missing message properties', async () => {
|
||||
// Test the processing logic with incomplete message data
|
||||
const incompleteUpdate = {
|
||||
update_id: 2001,
|
||||
message: {
|
||||
// Missing text and other properties
|
||||
message_id: 601,
|
||||
chat: { id: 987654321 },
|
||||
},
|
||||
};
|
||||
|
||||
// The actual processing would skip this message due to missing text
|
||||
const hasText =
|
||||
incompleteUpdate.message && incompleteUpdate.message.text;
|
||||
expect(hasText).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,149 +4,158 @@ const { User } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Telegram Routes', () => {
|
||||
let user, agent;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/telegram/setup', () => {
|
||||
it('should setup telegram bot token', async () => {
|
||||
const botToken = '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678';
|
||||
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token: botToken });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Telegram bot token updated successfully');
|
||||
|
||||
// Verify token was saved to user
|
||||
const updatedUser = await User.findByPk(user.id);
|
||||
expect(updatedUser.telegram_bot_token).toBe(botToken);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-1234567890' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require token parameter', async () => {
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Telegram bot token is required.');
|
||||
});
|
||||
|
||||
it('should validate token format', async () => {
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token: 'invalid-token-format' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid Telegram bot token format.');
|
||||
});
|
||||
|
||||
it('should validate token format with correct pattern', async () => {
|
||||
// Test various invalid formats
|
||||
const invalidTokens = [
|
||||
'123456:short',
|
||||
'notnum:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
'123456789-ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
'123456789:',
|
||||
':ABCdefGHIjklMNOPQRSTUVwxyz-12345678'
|
||||
];
|
||||
|
||||
for (const token of invalidTokens) {
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid Telegram bot token format.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid token formats', async () => {
|
||||
const validTokens = [
|
||||
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
'987654321:XYZabcDEFghiJKLmnoPQRstUVW_09876543',
|
||||
'555555555:abcdefghijklmnopqrstuvwxyzABCDEFGHI'
|
||||
];
|
||||
|
||||
for (const token of validTokens) {
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Telegram bot token updated successfully');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/telegram/start-polling', () => {
|
||||
beforeEach(async () => {
|
||||
// Setup bot token first
|
||||
await user.update({
|
||||
telegram_bot_token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678'
|
||||
});
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/telegram/start-polling');
|
||||
describe('POST /api/telegram/setup', () => {
|
||||
it('should setup telegram bot token', async () => {
|
||||
const botToken = '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678';
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token: botToken });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(
|
||||
'Telegram bot token updated successfully'
|
||||
);
|
||||
|
||||
// Verify token was saved to user
|
||||
const updatedUser = await User.findByPk(user.id);
|
||||
expect(updatedUser.telegram_bot_token).toBe(botToken);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/telegram/setup')
|
||||
.send({
|
||||
token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-1234567890',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require token parameter', async () => {
|
||||
const response = await agent.post('/api/telegram/setup').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Telegram bot token is required.');
|
||||
});
|
||||
|
||||
it('should validate token format', async () => {
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token: 'invalid-token-format' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe(
|
||||
'Invalid Telegram bot token format.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate token format with correct pattern', async () => {
|
||||
// Test various invalid formats
|
||||
const invalidTokens = [
|
||||
'123456:short',
|
||||
'notnum:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
'123456789-ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
'123456789:',
|
||||
':ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
];
|
||||
|
||||
for (const token of invalidTokens) {
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe(
|
||||
'Invalid Telegram bot token format.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept valid token formats', async () => {
|
||||
const validTokens = [
|
||||
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
'987654321:XYZabcDEFghiJKLmnoPQRstUVW_09876543',
|
||||
'555555555:abcdefghijklmnopqrstuvwxyzABCDEFGHI',
|
||||
];
|
||||
|
||||
for (const token of validTokens) {
|
||||
const response = await agent
|
||||
.post('/api/telegram/setup')
|
||||
.send({ token });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe(
|
||||
'Telegram bot token updated successfully'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should require bot token to be configured', async () => {
|
||||
// Remove bot token
|
||||
await user.update({ telegram_bot_token: null });
|
||||
describe('POST /api/telegram/start-polling', () => {
|
||||
beforeEach(async () => {
|
||||
// Setup bot token first
|
||||
await user.update({
|
||||
telegram_bot_token:
|
||||
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
|
||||
});
|
||||
});
|
||||
|
||||
const response = await agent
|
||||
.post('/api/telegram/start-polling');
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).post(
|
||||
'/api/telegram/start-polling'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Telegram bot token not set.');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require bot token to be configured', async () => {
|
||||
// Remove bot token
|
||||
await user.update({ telegram_bot_token: null });
|
||||
|
||||
const response = await agent.post('/api/telegram/start-polling');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Telegram bot token not set.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/telegram/stop-polling', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/telegram/stop-polling');
|
||||
describe('POST /api/telegram/stop-polling', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).post(
|
||||
'/api/telegram/stop-polling'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/telegram/polling-status', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/telegram/polling-status');
|
||||
describe('GET /api/telegram/polling-status', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(
|
||||
'/api/telegram/polling-status'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,178 +3,191 @@ const app = require('../../app');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('URL Routes', () => {
|
||||
let user, agent;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
describe('GET /api/url/title', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'https://example.com' });
|
||||
|
||||
describe('GET /api/url/title', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'https://example.com' });
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
it('should require url parameter', async () => {
|
||||
const response = await agent.get('/api/url/title');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('URL parameter is required');
|
||||
});
|
||||
|
||||
it('should return title for valid URL', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'https://httpbin.org/html' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe('https://httpbin.org/html');
|
||||
// Title could be extracted or null depending on network conditions
|
||||
expect(
|
||||
typeof response.body.title === 'string' ||
|
||||
response.body.title === null
|
||||
).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('should handle URL without protocol', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'httpbin.org/html' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe('httpbin.org/html');
|
||||
// Title could be extracted or null depending on network conditions
|
||||
expect(
|
||||
typeof response.body.title === 'string' ||
|
||||
response.body.title === null
|
||||
).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('should handle invalid URL gracefully', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'not-a-valid-url' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe('not-a-valid-url');
|
||||
// Title could be null or error message
|
||||
expect(
|
||||
response.body.title === null ||
|
||||
typeof response.body.title === 'string'
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle unreachable URL', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'https://nonexistent-domain-12345.com' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe(
|
||||
'https://nonexistent-domain-12345.com'
|
||||
);
|
||||
expect(response.body.title).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('should require url parameter', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title');
|
||||
describe('POST /api/url/extract-from-text', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: 'Check out https://example.com' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('URL parameter is required');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require text parameter', async () => {
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Text parameter is required');
|
||||
});
|
||||
|
||||
it('should extract URL from text and get title', async () => {
|
||||
const testText =
|
||||
'Check out this interesting site: https://httpbin.org/html';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(true);
|
||||
expect(response.body.url).toBe('https://httpbin.org/html');
|
||||
expect(response.body.originalText).toBe(testText);
|
||||
expect(response.body).toHaveProperty('title');
|
||||
// Title could be extracted or null depending on network conditions
|
||||
expect(
|
||||
typeof response.body.title === 'string' ||
|
||||
response.body.title === null
|
||||
).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('should extract first URL when multiple URLs in text', async () => {
|
||||
const testText =
|
||||
'Check out https://httpbin.org/html and also https://example.com';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(true);
|
||||
expect(response.body.url).toBe('https://httpbin.org/html');
|
||||
expect(response.body.originalText).toBe(testText);
|
||||
expect(response.body).toHaveProperty('title');
|
||||
}, 10000);
|
||||
|
||||
it('should detect URLs without protocol', async () => {
|
||||
const testText = 'Visit httpbin.org/html for testing';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(true);
|
||||
expect(response.body.url).toBe('httpbin.org/html');
|
||||
expect(response.body.originalText).toBe(testText);
|
||||
});
|
||||
|
||||
it('should return found false when no URL in text', async () => {
|
||||
const testText = 'This text has no URLs in it at all';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty text', async () => {
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Text parameter is required');
|
||||
});
|
||||
|
||||
it('should handle text with only whitespace', async () => {
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: ' \n\t ' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return title for valid URL', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'https://httpbin.org/html' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe('https://httpbin.org/html');
|
||||
// Title could be extracted or null depending on network conditions
|
||||
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('should handle URL without protocol', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'httpbin.org/html' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe('httpbin.org/html');
|
||||
// Title could be extracted or null depending on network conditions
|
||||
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('should handle invalid URL gracefully', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'not-a-valid-url' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe('not-a-valid-url');
|
||||
// Title could be null or error message
|
||||
expect(response.body.title === null || typeof response.body.title === 'string').toBe(true);
|
||||
});
|
||||
|
||||
it('should handle unreachable URL', async () => {
|
||||
const response = await agent
|
||||
.get('/api/url/title')
|
||||
.query({ url: 'https://nonexistent-domain-12345.com' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('url');
|
||||
expect(response.body).toHaveProperty('title');
|
||||
expect(response.body.url).toBe('https://nonexistent-domain-12345.com');
|
||||
expect(response.body.title).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/url/extract-from-text', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: 'Check out https://example.com' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should require text parameter', async () => {
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Text parameter is required');
|
||||
});
|
||||
|
||||
it('should extract URL from text and get title', async () => {
|
||||
const testText = 'Check out this interesting site: https://httpbin.org/html';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(true);
|
||||
expect(response.body.url).toBe('https://httpbin.org/html');
|
||||
expect(response.body.originalText).toBe(testText);
|
||||
expect(response.body).toHaveProperty('title');
|
||||
// Title could be extracted or null depending on network conditions
|
||||
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('should extract first URL when multiple URLs in text', async () => {
|
||||
const testText = 'Check out https://httpbin.org/html and also https://example.com';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(true);
|
||||
expect(response.body.url).toBe('https://httpbin.org/html');
|
||||
expect(response.body.originalText).toBe(testText);
|
||||
expect(response.body).toHaveProperty('title');
|
||||
}, 10000);
|
||||
|
||||
it('should detect URLs without protocol', async () => {
|
||||
const testText = 'Visit httpbin.org/html for testing';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(true);
|
||||
expect(response.body.url).toBe('httpbin.org/html');
|
||||
expect(response.body.originalText).toBe(testText);
|
||||
});
|
||||
|
||||
it('should return found false when no URL in text', async () => {
|
||||
const testText = 'This text has no URLs in it at all';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty text', async () => {
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Text parameter is required');
|
||||
});
|
||||
|
||||
it('should handle text with only whitespace', async () => {
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: ' \n\t ' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,323 +3,334 @@ const path = require('path');
|
|||
const { User } = require('../../models');
|
||||
|
||||
describe('User Create Script', () => {
|
||||
const scriptPath = path.join(__dirname, '../../scripts/user-create.js');
|
||||
|
||||
// Helper function to run the script and capture output
|
||||
const runUserCreateScript = (args = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [scriptPath, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, NODE_ENV: 'test' }
|
||||
});
|
||||
const scriptPath = path.join(__dirname, '../../scripts/user-create.js');
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
// Helper function to run the script and capture output
|
||||
const runUserCreateScript = (args = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [scriptPath, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
code,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim()
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
code,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up any test users created during tests
|
||||
await User.destroy({
|
||||
where: {
|
||||
email: ['testuser@example.com', 'admin@example.com', 'invalid-email', 'existing@example.com']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Success Cases', () => {
|
||||
it('should create a new user with valid email and password', async () => {
|
||||
const email = 'testuser@example.com';
|
||||
const password = 'securepassword123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
expect(result.stdout).toContain(`📧 Email: ${email}`);
|
||||
expect(result.stdout).toContain('🆔 User ID:');
|
||||
expect(result.stdout).toContain('📅 Created:');
|
||||
|
||||
// Verify user was actually created in database
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
expect(createdUser.email).toBe(email);
|
||||
expect(createdUser.password_digest).toBeTruthy();
|
||||
expect(createdUser.password_digest).not.toBe(password); // Should be hashed
|
||||
afterEach(async () => {
|
||||
// Clean up any test users created during tests
|
||||
await User.destroy({
|
||||
where: {
|
||||
email: [
|
||||
'testuser@example.com',
|
||||
'admin@example.com',
|
||||
'invalid-email',
|
||||
'existing@example.com',
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create user with minimum password length', async () => {
|
||||
const email = 'testuser2@example.com';
|
||||
const password = '123456'; // Exactly 6 characters
|
||||
describe('Success Cases', () => {
|
||||
it('should create a new user with valid email and password', async () => {
|
||||
const email = 'testuser@example.com';
|
||||
const password = 'securepassword123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
expect(result.stdout).toContain(`📧 Email: ${email}`);
|
||||
expect(result.stdout).toContain('🆔 User ID:');
|
||||
expect(result.stdout).toContain('📅 Created:');
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
// Verify user was actually created in database
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
expect(createdUser.email).toBe(email);
|
||||
expect(createdUser.password_digest).toBeTruthy();
|
||||
expect(createdUser.password_digest).not.toBe(password); // Should be hashed
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
it('should create user with minimum password length', async () => {
|
||||
const email = 'testuser2@example.com';
|
||||
const password = '123456'; // Exactly 6 characters
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
|
||||
it('should create user with complex email format', async () => {
|
||||
const email = 'user.name+tag@sub.domain.com';
|
||||
const password = 'password123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
|
||||
it('should create user with complex email format', async () => {
|
||||
const email = 'user.name+tag@sub.domain.com';
|
||||
const password = 'password123';
|
||||
describe('Error Cases', () => {
|
||||
it('should show usage when no arguments provided', async () => {
|
||||
const result = await runUserCreateScript([]);
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
'❌ Usage: npm run user:create <email> <password>'
|
||||
);
|
||||
expect(result.stderr).toContain(
|
||||
'Example: npm run user:create admin@example.com mypassword123'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
it('should show usage when only email provided', async () => {
|
||||
const result = await runUserCreateScript(['test@example.com']);
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
'❌ Usage: npm run user:create <email> <password>'
|
||||
);
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
it('should show usage when only password provided', async () => {
|
||||
const result = await runUserCreateScript(['', 'password123']);
|
||||
|
||||
describe('Error Cases', () => {
|
||||
it('should show usage when no arguments provided', async () => {
|
||||
const result = await runUserCreateScript([]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
'❌ Usage: npm run user:create <email> <password>'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
|
||||
expect(result.stderr).toContain('Example: npm run user:create admin@example.com mypassword123');
|
||||
});
|
||||
it('should reject invalid email format', async () => {
|
||||
const invalidEmails = [
|
||||
'invalid-email',
|
||||
'missing@domain',
|
||||
'@missing-local.com',
|
||||
'spaces in@email.com',
|
||||
'double@@domain.com',
|
||||
'trailing.dot.@domain.com',
|
||||
];
|
||||
|
||||
it('should show usage when only email provided', async () => {
|
||||
const result = await runUserCreateScript(['test@example.com']);
|
||||
for (const email of invalidEmails) {
|
||||
const result = await runUserCreateScript([
|
||||
email,
|
||||
'password123',
|
||||
]);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
|
||||
});
|
||||
|
||||
it('should show usage when only password provided', async () => {
|
||||
const result = await runUserCreateScript(['', 'password123']);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
|
||||
});
|
||||
|
||||
it('should reject invalid email format', async () => {
|
||||
const invalidEmails = [
|
||||
'invalid-email',
|
||||
'missing@domain',
|
||||
'@missing-local.com',
|
||||
'spaces in@email.com',
|
||||
'double@@domain.com',
|
||||
'trailing.dot.@domain.com'
|
||||
];
|
||||
|
||||
for (const email of invalidEmails) {
|
||||
const result = await runUserCreateScript([email, 'password123']);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Invalid email format');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject password shorter than 6 characters', async () => {
|
||||
const shortPasswords = ['', '1', '12', '123', '1234', '12345'];
|
||||
|
||||
for (const password of shortPasswords) {
|
||||
const result = await runUserCreateScript(['test@example.com', password]);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Password must be at least 6 characters long');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject duplicate email', async () => {
|
||||
const email = 'existing@example.com';
|
||||
const password = 'password123';
|
||||
|
||||
// Create user first
|
||||
await User.create({
|
||||
email,
|
||||
password_digest: await require('bcrypt').hash(password, 10)
|
||||
});
|
||||
|
||||
// Try to create same user again
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain(`❌ User with email ${email} already exists`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with npm script', () => {
|
||||
it('should work when called via npm run command', async () => {
|
||||
const email = 'npmtest@example.com';
|
||||
const password = 'testpassword123';
|
||||
|
||||
try {
|
||||
// This simulates running: npm run user:create npmtest@example.com testpassword123
|
||||
const output = execSync(
|
||||
`npm run user:create ${email} ${password}`,
|
||||
{
|
||||
cwd: path.join(__dirname, '../..'),
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
expect(output).toContain('User created successfully');
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
expect(createdUser.email).toBe(email);
|
||||
|
||||
} catch (error) {
|
||||
// If the command failed, check if it's due to duplicate user (from previous test runs)
|
||||
if (error.stderr?.includes('already exists')) {
|
||||
// Clean up and retry
|
||||
await User.destroy({ where: { email } });
|
||||
|
||||
const output = execSync(
|
||||
`npm run user:create ${email} ${password}`,
|
||||
{
|
||||
cwd: path.join(__dirname, '../..'),
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Invalid email format');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(output).toContain('User created successfully');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
}
|
||||
});
|
||||
});
|
||||
it('should reject password shorter than 6 characters', async () => {
|
||||
const shortPasswords = ['', '1', '12', '123', '1234', '12345'];
|
||||
|
||||
describe('Database Validation', () => {
|
||||
it('should hash password properly', async () => {
|
||||
const email = 'hashtest@example.com';
|
||||
const password = 'plaintextpassword';
|
||||
for (const password of shortPasswords) {
|
||||
const result = await runUserCreateScript([
|
||||
'test@example.com',
|
||||
password,
|
||||
]);
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
'❌ Password must be at least 6 characters long'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
it('should reject duplicate email', async () => {
|
||||
const email = 'existing@example.com';
|
||||
const password = 'password123';
|
||||
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Password should be hashed (bcrypt hashes start with $2b$)
|
||||
expect(createdUser.password_digest).toMatch(/^\$2b\$10\$/);
|
||||
expect(createdUser.password_digest).not.toBe(password);
|
||||
expect(createdUser.password_digest.length).toBeGreaterThan(50);
|
||||
// Create user first
|
||||
await User.create({
|
||||
email,
|
||||
password_digest: await require('bcrypt').hash(password, 10),
|
||||
});
|
||||
|
||||
// Verify the hash is valid
|
||||
const bcrypt = require('bcrypt');
|
||||
const isValid = await bcrypt.compare(password, createdUser.password_digest);
|
||||
expect(isValid).toBe(true);
|
||||
// Try to create same user again
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
`❌ User with email ${email} already exists`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set correct default values', async () => {
|
||||
const email = 'defaultstest@example.com';
|
||||
const password = 'password123';
|
||||
describe('Integration with npm script', () => {
|
||||
it('should work when called via npm run command', async () => {
|
||||
const email = 'npmtest@example.com';
|
||||
const password = 'testpassword123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
// Clean up any existing user first
|
||||
await User.destroy({ where: { email } });
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
try {
|
||||
// This simulates running: npm run user:create npmtest@example.com testpassword123
|
||||
const output = execSync(
|
||||
`npm run user:create ${email} ${password}`,
|
||||
{
|
||||
cwd: path.join(__dirname, '../..'),
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Check that created_at and updated_at are set
|
||||
expect(createdUser.created_at).toBeTruthy();
|
||||
expect(createdUser.updated_at).toBeTruthy();
|
||||
|
||||
// Check that it's a valid date
|
||||
expect(createdUser.created_at instanceof Date).toBe(true);
|
||||
expect(createdUser.updated_at instanceof Date).toBe(true);
|
||||
expect(output).toContain('User created successfully');
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in password', async () => {
|
||||
const email = 'specialchars@example.com';
|
||||
const password = 'p@ssw0rd!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Verify user was created and password works
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const isValid = await bcrypt.compare(password, createdUser.password_digest);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
expect(createdUser.email).toBe(email);
|
||||
} finally {
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle very long email', async () => {
|
||||
const longEmail = 'a'.repeat(50) + '@' + 'b'.repeat(50) + '.com';
|
||||
const password = 'password123';
|
||||
describe('Database Validation', () => {
|
||||
it('should hash password properly', async () => {
|
||||
const email = 'hashtest@example.com';
|
||||
const password = 'plaintextpassword';
|
||||
|
||||
const result = await runUserCreateScript([longEmail, password]);
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email: longEmail } });
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Password should be hashed (bcrypt hashes start with $2b$)
|
||||
expect(createdUser.password_digest).toMatch(/^\$2b\$10\$/);
|
||||
expect(createdUser.password_digest).not.toBe(password);
|
||||
expect(createdUser.password_digest.length).toBeGreaterThan(50);
|
||||
|
||||
// Verify the hash is valid
|
||||
const bcrypt = require('bcrypt');
|
||||
const isValid = await bcrypt.compare(
|
||||
password,
|
||||
createdUser.password_digest
|
||||
);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
|
||||
it('should set correct default values', async () => {
|
||||
const email = 'defaultstest@example.com';
|
||||
const password = 'password123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Check that created_at and updated_at are set
|
||||
expect(createdUser.created_at).toBeTruthy();
|
||||
expect(createdUser.updated_at).toBeTruthy();
|
||||
|
||||
// Check that it's a valid date
|
||||
expect(createdUser.created_at instanceof Date).toBe(true);
|
||||
expect(createdUser.updated_at instanceof Date).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle very long password', async () => {
|
||||
const email = 'longpassword@example.com';
|
||||
const password = 'a'.repeat(200); // Very long password
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in password', async () => {
|
||||
const email = 'specialchars@example.com';
|
||||
const password = 'p@ssw0rd!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
// Verify user was created and password works
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const isValid = await bcrypt.compare(
|
||||
password,
|
||||
createdUser.password_digest
|
||||
);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
|
||||
it('should handle very long email', async () => {
|
||||
const longEmail = 'a'.repeat(50) + '@' + 'b'.repeat(50) + '.com';
|
||||
const password = 'password123';
|
||||
|
||||
const result = await runUserCreateScript([longEmail, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email: longEmail } });
|
||||
});
|
||||
|
||||
it('should handle very long password', async () => {
|
||||
const email = 'longpassword@example.com';
|
||||
const password = 'a'.repeat(200); // Very long password
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,280 +4,313 @@ const { User } = require('../../models');
|
|||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Users Routes', () => {
|
||||
let user, agent;
|
||||
let user, agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com'
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent
|
||||
.post('/api/login')
|
||||
.send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/profile', () => {
|
||||
it('should get user profile', async () => {
|
||||
const response = await agent.get('/api/profile');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(user.id);
|
||||
expect(response.body.email).toBe(user.email);
|
||||
expect(response.body).toHaveProperty('appearance');
|
||||
expect(response.body).toHaveProperty('language');
|
||||
expect(response.body).toHaveProperty('timezone');
|
||||
expect(response.body).toHaveProperty('avatar_image');
|
||||
expect(response.body).toHaveProperty('telegram_bot_token');
|
||||
expect(response.body).toHaveProperty('telegram_chat_id');
|
||||
expect(response.body).toHaveProperty('task_summary_enabled');
|
||||
expect(response.body).toHaveProperty('task_summary_frequency');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/profile');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent.get('/api/profile');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/profile', () => {
|
||||
it('should update user profile', async () => {
|
||||
const updateData = {
|
||||
appearance: 'dark',
|
||||
language: 'es',
|
||||
timezone: 'UTC',
|
||||
avatar_image: 'new-avatar.png',
|
||||
telegram_bot_token: 'new-token'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch('/api/profile')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.appearance).toBe(updateData.appearance);
|
||||
expect(response.body.language).toBe(updateData.language);
|
||||
expect(response.body.timezone).toBe(updateData.timezone);
|
||||
expect(response.body.avatar_image).toBe(updateData.avatar_image);
|
||||
expect(response.body.telegram_bot_token).toBe(updateData.telegram_bot_token);
|
||||
});
|
||||
|
||||
it('should allow partial updates', async () => {
|
||||
const updateData = {
|
||||
appearance: 'dark'
|
||||
};
|
||||
|
||||
const response = await agent
|
||||
.patch('/api/profile')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.appearance).toBe(updateData.appearance);
|
||||
expect(response.body.language).toBe(user.language);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const updateData = {
|
||||
appearance: 'dark'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/api/profile')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent
|
||||
.patch('/api/profile')
|
||||
.send({ appearance: 'dark' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/profile/task-summary/toggle', () => {
|
||||
beforeEach(async () => {
|
||||
await user.update({ task_summary_enabled: false });
|
||||
user = await createTestUser({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
// Create authenticated agent
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle task summary on', async () => {
|
||||
const response = await agent.post('/api/profile/task-summary/toggle');
|
||||
describe('GET /api/profile', () => {
|
||||
it('should get user profile', async () => {
|
||||
const response = await agent.get('/api/profile');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.enabled).toBe(true);
|
||||
expect(response.body.message).toBe('Task summary notifications have been enabled.');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBe(user.id);
|
||||
expect(response.body.email).toBe(user.email);
|
||||
expect(response.body).toHaveProperty('appearance');
|
||||
expect(response.body).toHaveProperty('language');
|
||||
expect(response.body).toHaveProperty('timezone');
|
||||
expect(response.body).toHaveProperty('avatar_image');
|
||||
expect(response.body).toHaveProperty('telegram_bot_token');
|
||||
expect(response.body).toHaveProperty('telegram_chat_id');
|
||||
expect(response.body).toHaveProperty('task_summary_enabled');
|
||||
expect(response.body).toHaveProperty('task_summary_frequency');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/profile');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent.get('/api/profile');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle task summary off', async () => {
|
||||
await user.update({ task_summary_enabled: true });
|
||||
describe('PATCH /api/profile', () => {
|
||||
it('should update user profile', async () => {
|
||||
const updateData = {
|
||||
appearance: 'dark',
|
||||
language: 'es',
|
||||
timezone: 'UTC',
|
||||
avatar_image: 'new-avatar.png',
|
||||
telegram_bot_token: 'new-token',
|
||||
};
|
||||
|
||||
const response = await agent.post('/api/profile/task-summary/toggle');
|
||||
const response = await agent.patch('/api/profile').send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.enabled).toBe(false);
|
||||
expect(response.body.message).toBe('Task summary notifications have been disabled.');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.appearance).toBe(updateData.appearance);
|
||||
expect(response.body.language).toBe(updateData.language);
|
||||
expect(response.body.timezone).toBe(updateData.timezone);
|
||||
expect(response.body.avatar_image).toBe(updateData.avatar_image);
|
||||
expect(response.body.telegram_bot_token).toBe(
|
||||
updateData.telegram_bot_token
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow partial updates', async () => {
|
||||
const updateData = {
|
||||
appearance: 'dark',
|
||||
};
|
||||
|
||||
const response = await agent.patch('/api/profile').send(updateData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.appearance).toBe(updateData.appearance);
|
||||
expect(response.body.language).toBe(user.language);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const updateData = {
|
||||
appearance: 'dark',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/api/profile')
|
||||
.send(updateData);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent
|
||||
.patch('/api/profile')
|
||||
.send({ appearance: 'dark' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).post('/api/profile/task-summary/toggle');
|
||||
describe('POST /api/profile/task-summary/toggle', () => {
|
||||
beforeEach(async () => {
|
||||
await user.update({ task_summary_enabled: false });
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
it('should toggle task summary on', async () => {
|
||||
const response = await agent.post(
|
||||
'/api/profile/task-summary/toggle'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.enabled).toBe(true);
|
||||
expect(response.body.message).toBe(
|
||||
'Task summary notifications have been enabled.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle task summary off', async () => {
|
||||
await user.update({ task_summary_enabled: true });
|
||||
|
||||
const response = await agent.post(
|
||||
'/api/profile/task-summary/toggle'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.enabled).toBe(false);
|
||||
expect(response.body.message).toBe(
|
||||
'Task summary notifications have been disabled.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).post(
|
||||
'/api/profile/task-summary/toggle'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent.post(
|
||||
'/api/profile/task-summary/toggle'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
describe('POST /api/profile/task-summary/frequency', () => {
|
||||
it('should update task summary frequency', async () => {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'daily' });
|
||||
|
||||
const response = await agent.post('/api/profile/task-summary/toggle');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.frequency).toBe('daily');
|
||||
expect(response.body.message).toBe(
|
||||
'Task summary frequency has been set to daily.'
|
||||
);
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
it('should require frequency parameter', async () => {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({});
|
||||
|
||||
describe('POST /api/profile/task-summary/frequency', () => {
|
||||
it('should update task summary frequency', async () => {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'daily' });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Frequency is required.');
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.frequency).toBe('daily');
|
||||
expect(response.body.message).toBe('Task summary frequency has been set to daily.');
|
||||
it('should validate frequency value', async () => {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'invalid' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid frequency value.');
|
||||
});
|
||||
|
||||
it('should accept valid frequencies', async () => {
|
||||
const validFrequencies = [
|
||||
'daily',
|
||||
'weekdays',
|
||||
'weekly',
|
||||
'1h',
|
||||
'2h',
|
||||
'4h',
|
||||
'8h',
|
||||
'12h',
|
||||
];
|
||||
|
||||
for (const frequency of validFrequencies) {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.frequency).toBe(frequency);
|
||||
}
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'daily' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'daily' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require frequency parameter', async () => {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({});
|
||||
describe('POST /api/profile/task-summary/send-now', () => {
|
||||
it('should require telegram configuration', async () => {
|
||||
const response = await agent.post(
|
||||
'/api/profile/task-summary/send-now'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Frequency is required.');
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe(
|
||||
'Telegram bot is not properly configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).post(
|
||||
'/api/profile/task-summary/send-now'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent.post(
|
||||
'/api/profile/task-summary/send-now'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate frequency value', async () => {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'invalid' });
|
||||
describe('GET /api/profile/task-summary/status', () => {
|
||||
it('should get task summary status', async () => {
|
||||
await user.update({
|
||||
task_summary_enabled: true,
|
||||
task_summary_frequency: 'daily',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid frequency value.');
|
||||
const response = await agent.get(
|
||||
'/api/profile/task-summary/status'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.enabled).toBe(true);
|
||||
expect(response.body.frequency).toBe('daily');
|
||||
expect(response.body).toHaveProperty('last_run');
|
||||
expect(response.body).toHaveProperty('next_run');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get(
|
||||
'/api/profile/task-summary/status'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent.get(
|
||||
'/api/profile/task-summary/status'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept valid frequencies', async () => {
|
||||
const validFrequencies = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h'];
|
||||
|
||||
for (const frequency of validFrequencies) {
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.frequency).toBe(frequency);
|
||||
}
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'daily' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent
|
||||
.post('/api/profile/task-summary/frequency')
|
||||
.send({ frequency: 'daily' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/profile/task-summary/send-now', () => {
|
||||
it('should require telegram configuration', async () => {
|
||||
const response = await agent.post('/api/profile/task-summary/send-now');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Telegram bot is not properly configured.');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).post('/api/profile/task-summary/send-now');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent.post('/api/profile/task-summary/send-now');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/profile/task-summary/status', () => {
|
||||
it('should get task summary status', async () => {
|
||||
await user.update({
|
||||
task_summary_enabled: true,
|
||||
task_summary_frequency: 'daily'
|
||||
});
|
||||
|
||||
const response = await agent.get('/api/profile/task-summary/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.enabled).toBe(true);
|
||||
expect(response.body.frequency).toBe('daily');
|
||||
expect(response.body).toHaveProperty('last_run');
|
||||
expect(response.body).toHaveProperty('next_run');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
const response = await request(app).get('/api/profile/task-summary/status');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('Authentication required');
|
||||
});
|
||||
|
||||
it('should return 401 when session user no longer exists', async () => {
|
||||
await User.destroy({ where: { id: user.id } });
|
||||
|
||||
const response = await agent.get('/api/profile/task-summary/status');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe('User not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,129 +2,137 @@ const { requireAuth } = require('../../../middleware/auth');
|
|||
const { User } = require('../../../models');
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
let req, res, next;
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
path: '/api/tasks',
|
||||
session: {}
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn()
|
||||
};
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
it('should skip authentication for health check', async () => {
|
||||
req.path = '/api/health';
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip authentication for login route', async () => {
|
||||
req.path = '/api/login';
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip authentication for current_user route', async () => {
|
||||
req.path = '/api/current_user';
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 if no session', async () => {
|
||||
req.session = null;
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 if no userId in session', async () => {
|
||||
req.session = {};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 and destroy session if user not found', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
path: '/api/tasks',
|
||||
session: {},
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
req.session = {
|
||||
userId: user.id + 1, // Non-existent user ID
|
||||
destroy: jest.fn()
|
||||
};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(req.session.destroy).toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set currentUser and call next for valid session', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
it('should skip authentication for health check', async () => {
|
||||
req.path = '/api/health';
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
req.session = {
|
||||
userId: user.id
|
||||
};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(req.currentUser).toBeDefined();
|
||||
expect(req.currentUser.id).toBe(user.id);
|
||||
expect(req.currentUser.email).toBe(user.email);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
// Mock console.error to suppress expected error log in test output
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
// Mock User.findByPk to throw an error
|
||||
const originalFindByPk = User.findByPk;
|
||||
User.findByPk = jest.fn().mockRejectedValue(new Error('Database connection error'));
|
||||
|
||||
req.session = {
|
||||
userId: 123,
|
||||
destroy: jest.fn()
|
||||
};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication error' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original methods
|
||||
User.findByPk = originalFindByPk;
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
});
|
||||
it('should skip authentication for login route', async () => {
|
||||
req.path = '/api/login';
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip authentication for current_user route', async () => {
|
||||
req.path = '/api/current_user';
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 if no session', async () => {
|
||||
req.session = null;
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Authentication required',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 if no userId in session', async () => {
|
||||
req.session = {};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Authentication required',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 and destroy session if user not found', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
req.session = {
|
||||
userId: user.id + 1, // Non-existent user ID
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(req.session.destroy).toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set currentUser and call next for valid session', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
req.session = {
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(req.currentUser).toBeDefined();
|
||||
expect(req.currentUser.id).toBe(user.id);
|
||||
expect(req.currentUser.email).toBe(user.email);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
// Mock console.error to suppress expected error log in test output
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
// Mock User.findByPk to throw an error
|
||||
const originalFindByPk = User.findByPk;
|
||||
User.findByPk = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Database connection error'));
|
||||
|
||||
req.session = {
|
||||
userId: 123,
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
|
||||
await requireAuth(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Authentication error',
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original methods
|
||||
User.findByPk = originalFindByPk;
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,74 +1,74 @@
|
|||
const { Area, User } = require('../../../models');
|
||||
|
||||
describe('Area Model', () => {
|
||||
let user;
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should create an area with valid data', async () => {
|
||||
const areaData = {
|
||||
name: 'Work',
|
||||
description: 'Work related projects',
|
||||
user_id: user.id
|
||||
};
|
||||
|
||||
const area = await Area.create(areaData);
|
||||
|
||||
expect(area.name).toBe(areaData.name);
|
||||
expect(area.description).toBe(areaData.description);
|
||||
expect(area.user_id).toBe(user.id);
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const areaData = {
|
||||
description: 'Area without name',
|
||||
user_id: user.id
|
||||
};
|
||||
describe('validation', () => {
|
||||
it('should create an area with valid data', async () => {
|
||||
const areaData = {
|
||||
name: 'Work',
|
||||
description: 'Work related projects',
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(Area.create(areaData)).rejects.toThrow();
|
||||
const area = await Area.create(areaData);
|
||||
|
||||
expect(area.name).toBe(areaData.name);
|
||||
expect(area.description).toBe(areaData.description);
|
||||
expect(area.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const areaData = {
|
||||
description: 'Area without name',
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(Area.create(areaData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const areaData = {
|
||||
name: 'Test Area',
|
||||
};
|
||||
|
||||
await expect(Area.create(areaData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should allow null description', async () => {
|
||||
const areaData = {
|
||||
name: 'Test Area',
|
||||
user_id: user.id,
|
||||
description: null,
|
||||
};
|
||||
|
||||
const area = await Area.create(areaData);
|
||||
expect(area.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const areaData = {
|
||||
name: 'Test Area'
|
||||
};
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const area = await Area.create({
|
||||
name: 'Test Area',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await expect(Area.create(areaData)).rejects.toThrow();
|
||||
const areaWithUser = await Area.findByPk(area.id, {
|
||||
include: [{ model: User }],
|
||||
});
|
||||
|
||||
expect(areaWithUser.User).toBeDefined();
|
||||
expect(areaWithUser.User.id).toBe(user.id);
|
||||
expect(areaWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow null description', async () => {
|
||||
const areaData = {
|
||||
name: 'Test Area',
|
||||
user_id: user.id,
|
||||
description: null
|
||||
};
|
||||
|
||||
const area = await Area.create(areaData);
|
||||
expect(area.description).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const area = await Area.create({
|
||||
name: 'Test Area',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const areaWithUser = await Area.findByPk(area.id, {
|
||||
include: [{ model: User }]
|
||||
});
|
||||
|
||||
expect(areaWithUser.User).toBeDefined();
|
||||
expect(areaWithUser.User.id).toBe(user.id);
|
||||
expect(areaWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,96 +1,96 @@
|
|||
const { InboxItem, User } = require('../../../models');
|
||||
|
||||
describe('InboxItem Model', () => {
|
||||
let user;
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should create an inbox item with valid data', async () => {
|
||||
const inboxData = {
|
||||
content: 'Remember to buy groceries',
|
||||
status: 'added',
|
||||
source: 'web',
|
||||
user_id: user.id
|
||||
};
|
||||
|
||||
const inboxItem = await InboxItem.create(inboxData);
|
||||
|
||||
expect(inboxItem.content).toBe(inboxData.content);
|
||||
expect(inboxItem.status).toBe(inboxData.status);
|
||||
expect(inboxItem.source).toBe(inboxData.source);
|
||||
expect(inboxItem.user_id).toBe(user.id);
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
});
|
||||
|
||||
it('should require content', async () => {
|
||||
const inboxData = {
|
||||
user_id: user.id
|
||||
};
|
||||
describe('validation', () => {
|
||||
it('should create an inbox item with valid data', async () => {
|
||||
const inboxData = {
|
||||
content: 'Remember to buy groceries',
|
||||
status: 'added',
|
||||
source: 'web',
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
const inboxItem = await InboxItem.create(inboxData);
|
||||
|
||||
expect(inboxItem.content).toBe(inboxData.content);
|
||||
expect(inboxItem.status).toBe(inboxData.status);
|
||||
expect(inboxItem.source).toBe(inboxData.source);
|
||||
expect(inboxItem.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require content', async () => {
|
||||
const inboxData = {
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content',
|
||||
};
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require status', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
status: null,
|
||||
};
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require source', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
source: null,
|
||||
};
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content'
|
||||
};
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
expect(inboxItem.status).toBe('added');
|
||||
expect(inboxItem.source).toBe('tududi');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require status', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
status: null
|
||||
};
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
const inboxItemWithUser = await InboxItem.findByPk(inboxItem.id, {
|
||||
include: [{ model: User }],
|
||||
});
|
||||
|
||||
expect(inboxItemWithUser.User).toBeDefined();
|
||||
expect(inboxItemWithUser.User.id).toBe(user.id);
|
||||
expect(inboxItemWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
|
||||
it('should require source', async () => {
|
||||
const inboxData = {
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
source: null
|
||||
};
|
||||
|
||||
await expect(InboxItem.create(inboxData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
expect(inboxItem.status).toBe('added');
|
||||
expect(inboxItem.source).toBe('tududi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const inboxItem = await InboxItem.create({
|
||||
content: 'Test content',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const inboxItemWithUser = await InboxItem.findByPk(inboxItem.id, {
|
||||
include: [{ model: User }]
|
||||
});
|
||||
|
||||
expect(inboxItemWithUser.User).toBeDefined();
|
||||
expect(inboxItemWithUser.User.id).toBe(user.id);
|
||||
expect(inboxItemWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,102 +1,102 @@
|
|||
const { Note, User, Project } = require('../../../models');
|
||||
|
||||
describe('Note Model', () => {
|
||||
let user, project;
|
||||
let user, project;
|
||||
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
describe('validation', () => {
|
||||
it('should create a note with valid data', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'This is a test note content',
|
||||
user_id: user.id,
|
||||
project_id: project.id,
|
||||
};
|
||||
|
||||
describe('validation', () => {
|
||||
it('should create a note with valid data', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'This is a test note content',
|
||||
user_id: user.id,
|
||||
project_id: project.id
|
||||
};
|
||||
const note = await Note.create(noteData);
|
||||
|
||||
const note = await Note.create(noteData);
|
||||
|
||||
expect(note.title).toBe(noteData.title);
|
||||
expect(note.content).toBe(noteData.content);
|
||||
expect(note.user_id).toBe(user.id);
|
||||
expect(note.project_id).toBe(project.id);
|
||||
expect(note.title).toBe(noteData.title);
|
||||
expect(note.content).toBe(noteData.content);
|
||||
expect(note.user_id).toBe(user.id);
|
||||
expect(note.project_id).toBe(project.id);
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
};
|
||||
|
||||
await expect(Note.create(noteData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should allow title and content to be null', async () => {
|
||||
const noteData = {
|
||||
title: null,
|
||||
content: null,
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
const note = await Note.create(noteData);
|
||||
expect(note.title).toBeNull();
|
||||
expect(note.content).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow project_id to be null', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
project_id: null,
|
||||
};
|
||||
|
||||
const note = await Note.create(noteData);
|
||||
expect(note.project_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'Test content'
|
||||
};
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const note = await Note.create({
|
||||
title: 'Test Note',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await expect(Note.create(noteData)).rejects.toThrow();
|
||||
const noteWithUser = await Note.findByPk(note.id, {
|
||||
include: [{ model: User }],
|
||||
});
|
||||
|
||||
expect(noteWithUser.User).toBeDefined();
|
||||
expect(noteWithUser.User.id).toBe(user.id);
|
||||
expect(noteWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
|
||||
it('should belong to a project', async () => {
|
||||
const note = await Note.create({
|
||||
title: 'Test Note',
|
||||
user_id: user.id,
|
||||
project_id: project.id,
|
||||
});
|
||||
|
||||
const noteWithProject = await Note.findByPk(note.id, {
|
||||
include: [{ model: Project }],
|
||||
});
|
||||
|
||||
expect(noteWithProject.Project).toBeDefined();
|
||||
expect(noteWithProject.Project.id).toBe(project.id);
|
||||
expect(noteWithProject.Project.name).toBe(project.name);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow title and content to be null', async () => {
|
||||
const noteData = {
|
||||
title: null,
|
||||
content: null,
|
||||
user_id: user.id
|
||||
};
|
||||
|
||||
const note = await Note.create(noteData);
|
||||
expect(note.title).toBeNull();
|
||||
expect(note.content).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow project_id to be null', async () => {
|
||||
const noteData = {
|
||||
title: 'Test Note',
|
||||
content: 'Test content',
|
||||
user_id: user.id,
|
||||
project_id: null
|
||||
};
|
||||
|
||||
const note = await Note.create(noteData);
|
||||
expect(note.project_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const note = await Note.create({
|
||||
title: 'Test Note',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const noteWithUser = await Note.findByPk(note.id, {
|
||||
include: [{ model: User }]
|
||||
});
|
||||
|
||||
expect(noteWithUser.User).toBeDefined();
|
||||
expect(noteWithUser.User.id).toBe(user.id);
|
||||
expect(noteWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
|
||||
it('should belong to a project', async () => {
|
||||
const note = await Note.create({
|
||||
title: 'Test Note',
|
||||
user_id: user.id,
|
||||
project_id: project.id
|
||||
});
|
||||
|
||||
const noteWithProject = await Note.findByPk(note.id, {
|
||||
include: [{ model: Project }]
|
||||
});
|
||||
|
||||
expect(noteWithProject.Project).toBeDefined();
|
||||
expect(noteWithProject.Project.id).toBe(project.id);
|
||||
expect(noteWithProject.Project.name).toBe(project.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,141 +1,141 @@
|
|||
const { Project, User, Area } = require('../../../models');
|
||||
|
||||
describe('Project Model', () => {
|
||||
let user, area;
|
||||
let user, area;
|
||||
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
user_id: user.id
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should create a project with valid data', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
priority: 1,
|
||||
user_id: user.id,
|
||||
area_id: area.id
|
||||
};
|
||||
|
||||
const project = await Project.create(projectData);
|
||||
|
||||
expect(project.name).toBe(projectData.name);
|
||||
expect(project.description).toBe(projectData.description);
|
||||
expect(project.active).toBe(projectData.active);
|
||||
expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
|
||||
expect(project.priority).toBe(projectData.priority);
|
||||
expect(project.user_id).toBe(user.id);
|
||||
expect(project.area_id).toBe(area.id);
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const projectData = {
|
||||
description: 'Project without name',
|
||||
user_id: user.id
|
||||
};
|
||||
|
||||
await expect(Project.create(projectData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project'
|
||||
};
|
||||
|
||||
await expect(Project.create(projectData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate priority range', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
priority: 5
|
||||
};
|
||||
|
||||
await expect(Project.create(projectData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should allow valid priority values', async () => {
|
||||
for (let priority of [0, 1, 2]) {
|
||||
const project = await Project.create({
|
||||
name: `Test Project ${priority}`,
|
||||
user_id: user.id,
|
||||
priority: priority
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
expect(project.priority).toBe(priority);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
expect(project.active).toBe(false);
|
||||
expect(project.pin_to_sidebar).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional fields', () => {
|
||||
it('should allow optional fields to be null', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
description: null,
|
||||
priority: null,
|
||||
due_date_at: null,
|
||||
area_id: null
|
||||
});
|
||||
|
||||
expect(project.description).toBeNull();
|
||||
expect(project.priority).toBeNull();
|
||||
expect(project.due_date_at).toBeNull();
|
||||
expect(project.area_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const projectWithUser = await Project.findByPk(project.id, {
|
||||
include: [{ model: User }]
|
||||
});
|
||||
|
||||
expect(projectWithUser.User).toBeDefined();
|
||||
expect(projectWithUser.User.id).toBe(user.id);
|
||||
area = await Area.create({
|
||||
name: 'Work',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should belong to an area', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
area_id: area.id
|
||||
});
|
||||
describe('validation', () => {
|
||||
it('should create a project with valid data', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
description: 'Test Description',
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
priority: 1,
|
||||
user_id: user.id,
|
||||
area_id: area.id,
|
||||
};
|
||||
|
||||
const projectWithArea = await Project.findByPk(project.id, {
|
||||
include: [{ model: Area }]
|
||||
});
|
||||
const project = await Project.create(projectData);
|
||||
|
||||
expect(projectWithArea.Area).toBeDefined();
|
||||
expect(projectWithArea.Area.id).toBe(area.id);
|
||||
expect(project.name).toBe(projectData.name);
|
||||
expect(project.description).toBe(projectData.description);
|
||||
expect(project.active).toBe(projectData.active);
|
||||
expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
|
||||
expect(project.priority).toBe(projectData.priority);
|
||||
expect(project.user_id).toBe(user.id);
|
||||
expect(project.area_id).toBe(area.id);
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const projectData = {
|
||||
description: 'Project without name',
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(Project.create(projectData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
};
|
||||
|
||||
await expect(Project.create(projectData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate priority range', async () => {
|
||||
const projectData = {
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
priority: 5,
|
||||
};
|
||||
|
||||
await expect(Project.create(projectData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should allow valid priority values', async () => {
|
||||
for (let priority of [0, 1, 2]) {
|
||||
const project = await Project.create({
|
||||
name: `Test Project ${priority}`,
|
||||
user_id: user.id,
|
||||
priority: priority,
|
||||
});
|
||||
expect(project.priority).toBe(priority);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
expect(project.active).toBe(false);
|
||||
expect(project.pin_to_sidebar).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional fields', () => {
|
||||
it('should allow optional fields to be null', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
description: null,
|
||||
priority: null,
|
||||
due_date_at: null,
|
||||
area_id: null,
|
||||
});
|
||||
|
||||
expect(project.description).toBeNull();
|
||||
expect(project.priority).toBeNull();
|
||||
expect(project.due_date_at).toBeNull();
|
||||
expect(project.area_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
const projectWithUser = await Project.findByPk(project.id, {
|
||||
include: [{ model: User }],
|
||||
});
|
||||
|
||||
expect(projectWithUser.User).toBeDefined();
|
||||
expect(projectWithUser.User.id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should belong to an area', async () => {
|
||||
const project = await Project.create({
|
||||
name: 'Test Project',
|
||||
user_id: user.id,
|
||||
area_id: area.id,
|
||||
});
|
||||
|
||||
const projectWithArea = await Project.findByPk(project.id, {
|
||||
include: [{ model: Area }],
|
||||
});
|
||||
|
||||
expect(projectWithArea.Area).toBeDefined();
|
||||
expect(projectWithArea.Area.id).toBe(area.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,83 +1,83 @@
|
|||
const { Tag, User } = require('../../../models');
|
||||
|
||||
describe('Tag Model', () => {
|
||||
let user;
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should create a tag with valid data', async () => {
|
||||
const tagData = {
|
||||
name: 'work',
|
||||
user_id: user.id
|
||||
};
|
||||
|
||||
const tag = await Tag.create(tagData);
|
||||
|
||||
expect(tag.name).toBe(tagData.name);
|
||||
expect(tag.user_id).toBe(user.id);
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const tagData = {
|
||||
user_id: user.id
|
||||
};
|
||||
describe('validation', () => {
|
||||
it('should create a tag with valid data', async () => {
|
||||
const tagData = {
|
||||
name: 'work',
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(Tag.create(tagData)).rejects.toThrow();
|
||||
const tag = await Tag.create(tagData);
|
||||
|
||||
expect(tag.name).toBe(tagData.name);
|
||||
expect(tag.user_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const tagData = {
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(Tag.create(tagData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const tagData = {
|
||||
name: 'work',
|
||||
};
|
||||
|
||||
await expect(Tag.create(tagData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should allow multiple tags with same name for different users', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
const tag1 = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
const tag2 = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
expect(tag1.name).toBe('work');
|
||||
expect(tag2.name).toBe('work');
|
||||
expect(tag1.user_id).toBe(user.id);
|
||||
expect(tag2.user_id).toBe(otherUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const tagData = {
|
||||
name: 'work'
|
||||
};
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await expect(Tag.create(tagData)).rejects.toThrow();
|
||||
const tagWithUser = await Tag.findByPk(tag.id, {
|
||||
include: [{ model: User }],
|
||||
});
|
||||
|
||||
expect(tagWithUser.User).toBeDefined();
|
||||
expect(tagWithUser.User.id).toBe(user.id);
|
||||
expect(tagWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow multiple tags with same name for different users', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
const tag1 = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const tag2 = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: otherUser.id
|
||||
});
|
||||
|
||||
expect(tag1.name).toBe('work');
|
||||
expect(tag2.name).toBe('work');
|
||||
expect(tag1.user_id).toBe(user.id);
|
||||
expect(tag2.user_id).toBe(otherUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('associations', () => {
|
||||
it('should belong to a user', async () => {
|
||||
const tag = await Tag.create({
|
||||
name: 'work',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
const tagWithUser = await Tag.findByPk(tag.id, {
|
||||
include: [{ model: User }]
|
||||
});
|
||||
|
||||
expect(tagWithUser.User).toBeDefined();
|
||||
expect(tagWithUser.User.id).toBe(user.id);
|
||||
expect(tagWithUser.User.email).toBe(user.email);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,183 +1,183 @@
|
|||
const { Task, User } = require('../../../models');
|
||||
|
||||
describe('Task Model', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should create a task with valid data', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
description: 'Test Description',
|
||||
user_id: user.id
|
||||
};
|
||||
|
||||
const task = await Task.create(taskData);
|
||||
|
||||
expect(task.name).toBe(taskData.name);
|
||||
expect(task.description).toBe(taskData.description);
|
||||
expect(task.user_id).toBe(user.id);
|
||||
expect(task.today).toBe(false);
|
||||
expect(task.priority).toBe(0);
|
||||
expect(task.status).toBe(0);
|
||||
expect(task.recurrence_type).toBe('none');
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const taskData = {
|
||||
user_id: user.id
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task'
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate priority range', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
priority: 5
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate status range', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
status: 10
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('should have correct priority constants', () => {
|
||||
expect(Task.PRIORITY.LOW).toBe(0);
|
||||
expect(Task.PRIORITY.MEDIUM).toBe(1);
|
||||
expect(Task.PRIORITY.HIGH).toBe(2);
|
||||
});
|
||||
|
||||
it('should have correct status constants', () => {
|
||||
expect(Task.STATUS.NOT_STARTED).toBe(0);
|
||||
expect(Task.STATUS.IN_PROGRESS).toBe(1);
|
||||
expect(Task.STATUS.DONE).toBe(2);
|
||||
expect(Task.STATUS.ARCHIVED).toBe(3);
|
||||
expect(Task.STATUS.WAITING).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance methods', () => {
|
||||
let task;
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id
|
||||
});
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct priority name', async () => {
|
||||
task.priority = Task.PRIORITY.LOW;
|
||||
expect(Task.getPriorityName(task.priority)).toBe('low');
|
||||
describe('validation', () => {
|
||||
it('should create a task with valid data', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
description: 'Test Description',
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
task.priority = Task.PRIORITY.MEDIUM;
|
||||
expect(Task.getPriorityName(task.priority)).toBe('medium');
|
||||
const task = await Task.create(taskData);
|
||||
|
||||
task.priority = Task.PRIORITY.HIGH;
|
||||
expect(Task.getPriorityName(task.priority)).toBe('high');
|
||||
expect(task.name).toBe(taskData.name);
|
||||
expect(task.description).toBe(taskData.description);
|
||||
expect(task.user_id).toBe(user.id);
|
||||
expect(task.today).toBe(false);
|
||||
expect(task.priority).toBe(0);
|
||||
expect(task.status).toBe(0);
|
||||
expect(task.recurrence_type).toBe('none');
|
||||
});
|
||||
|
||||
it('should require name', async () => {
|
||||
const taskData = {
|
||||
user_id: user.id,
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require user_id', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate priority range', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
priority: 5,
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate status range', async () => {
|
||||
const taskData = {
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
status: 10,
|
||||
};
|
||||
|
||||
await expect(Task.create(taskData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct status name', async () => {
|
||||
task.status = Task.STATUS.NOT_STARTED;
|
||||
expect(Task.getStatusName(task.status)).toBe('not_started');
|
||||
describe('constants', () => {
|
||||
it('should have correct priority constants', () => {
|
||||
expect(Task.PRIORITY.LOW).toBe(0);
|
||||
expect(Task.PRIORITY.MEDIUM).toBe(1);
|
||||
expect(Task.PRIORITY.HIGH).toBe(2);
|
||||
});
|
||||
|
||||
task.status = Task.STATUS.IN_PROGRESS;
|
||||
expect(Task.getStatusName(task.status)).toBe('in_progress');
|
||||
|
||||
task.status = Task.STATUS.DONE;
|
||||
expect(Task.getStatusName(task.status)).toBe('done');
|
||||
|
||||
task.status = Task.STATUS.ARCHIVED;
|
||||
expect(Task.getStatusName(task.status)).toBe('archived');
|
||||
|
||||
task.status = Task.STATUS.WAITING;
|
||||
expect(Task.getStatusName(task.status)).toBe('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
expect(task.today).toBe(false);
|
||||
expect(task.priority).toBe(0);
|
||||
expect(task.status).toBe(0);
|
||||
expect(task.recurrence_type).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional fields', () => {
|
||||
it('should allow optional fields to be null', async () => {
|
||||
const task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
description: null,
|
||||
due_date: null,
|
||||
note: null,
|
||||
recurrence_interval: null,
|
||||
recurrence_end_date: null,
|
||||
last_generated_date: null,
|
||||
project_id: null
|
||||
});
|
||||
|
||||
expect(task.description).toBeNull();
|
||||
expect(task.due_date).toBeNull();
|
||||
expect(task.note).toBeNull();
|
||||
expect(task.recurrence_interval).toBeNull();
|
||||
expect(task.recurrence_end_date).toBeNull();
|
||||
expect(task.last_generated_date).toBeNull();
|
||||
expect(task.project_id).toBeNull();
|
||||
it('should have correct status constants', () => {
|
||||
expect(Task.STATUS.NOT_STARTED).toBe(0);
|
||||
expect(Task.STATUS.IN_PROGRESS).toBe(1);
|
||||
expect(Task.STATUS.DONE).toBe(2);
|
||||
expect(Task.STATUS.ARCHIVED).toBe(3);
|
||||
expect(Task.STATUS.WAITING).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept optional field values', async () => {
|
||||
const dueDate = new Date();
|
||||
const task = await Task.create({
|
||||
name: 'Test Task',
|
||||
description: 'Test Description',
|
||||
due_date: dueDate,
|
||||
today: true,
|
||||
priority: Task.PRIORITY.HIGH,
|
||||
status: Task.STATUS.IN_PROGRESS,
|
||||
note: 'Test Note',
|
||||
user_id: user.id
|
||||
});
|
||||
describe('instance methods', () => {
|
||||
let task;
|
||||
|
||||
expect(task.description).toBe('Test Description');
|
||||
expect(task.due_date).toEqual(dueDate);
|
||||
expect(task.today).toBe(true);
|
||||
expect(task.priority).toBe(Task.PRIORITY.HIGH);
|
||||
expect(task.status).toBe(Task.STATUS.IN_PROGRESS);
|
||||
expect(task.note).toBe('Test Note');
|
||||
beforeEach(async () => {
|
||||
task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return correct priority name', async () => {
|
||||
task.priority = Task.PRIORITY.LOW;
|
||||
expect(Task.getPriorityName(task.priority)).toBe('low');
|
||||
|
||||
task.priority = Task.PRIORITY.MEDIUM;
|
||||
expect(Task.getPriorityName(task.priority)).toBe('medium');
|
||||
|
||||
task.priority = Task.PRIORITY.HIGH;
|
||||
expect(Task.getPriorityName(task.priority)).toBe('high');
|
||||
});
|
||||
|
||||
it('should return correct status name', async () => {
|
||||
task.status = Task.STATUS.NOT_STARTED;
|
||||
expect(Task.getStatusName(task.status)).toBe('not_started');
|
||||
|
||||
task.status = Task.STATUS.IN_PROGRESS;
|
||||
expect(Task.getStatusName(task.status)).toBe('in_progress');
|
||||
|
||||
task.status = Task.STATUS.DONE;
|
||||
expect(Task.getStatusName(task.status)).toBe('done');
|
||||
|
||||
task.status = Task.STATUS.ARCHIVED;
|
||||
expect(Task.getStatusName(task.status)).toBe('archived');
|
||||
|
||||
task.status = Task.STATUS.WAITING;
|
||||
expect(Task.getStatusName(task.status)).toBe('waiting');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
expect(task.today).toBe(false);
|
||||
expect(task.priority).toBe(0);
|
||||
expect(task.status).toBe(0);
|
||||
expect(task.recurrence_type).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional fields', () => {
|
||||
it('should allow optional fields to be null', async () => {
|
||||
const task = await Task.create({
|
||||
name: 'Test Task',
|
||||
user_id: user.id,
|
||||
description: null,
|
||||
due_date: null,
|
||||
note: null,
|
||||
recurrence_interval: null,
|
||||
recurrence_end_date: null,
|
||||
last_generated_date: null,
|
||||
project_id: null,
|
||||
});
|
||||
|
||||
expect(task.description).toBeNull();
|
||||
expect(task.due_date).toBeNull();
|
||||
expect(task.note).toBeNull();
|
||||
expect(task.recurrence_interval).toBeNull();
|
||||
expect(task.recurrence_end_date).toBeNull();
|
||||
expect(task.last_generated_date).toBeNull();
|
||||
expect(task.project_id).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept optional field values', async () => {
|
||||
const dueDate = new Date();
|
||||
const task = await Task.create({
|
||||
name: 'Test Task',
|
||||
description: 'Test Description',
|
||||
due_date: dueDate,
|
||||
today: true,
|
||||
priority: Task.PRIORITY.HIGH,
|
||||
status: Task.STATUS.IN_PROGRESS,
|
||||
note: 'Test Note',
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
expect(task.description).toBe('Test Description');
|
||||
expect(task.due_date).toEqual(dueDate);
|
||||
expect(task.today).toBe(true);
|
||||
expect(task.priority).toBe(Task.PRIORITY.HIGH);
|
||||
expect(task.status).toBe(Task.STATUS.IN_PROGRESS);
|
||||
expect(task.note).toBe('Test Note');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,137 +1,152 @@
|
|||
const { User } = require('../../../models');
|
||||
|
||||
describe('User Model', () => {
|
||||
describe('validation', () => {
|
||||
it('should create a user with valid data', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
};
|
||||
describe('validation', () => {
|
||||
it('should create a user with valid data', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
};
|
||||
|
||||
const user = await User.create(userData);
|
||||
|
||||
expect(user.email).toBe(userData.email);
|
||||
expect(user.password_digest).toBeDefined();
|
||||
expect(user.password_digest).toBe(userData.password_digest);
|
||||
expect(user.appearance).toBe('light');
|
||||
expect(user.language).toBe('en');
|
||||
expect(user.timezone).toBe('UTC');
|
||||
const user = await User.create(userData);
|
||||
|
||||
expect(user.email).toBe(userData.email);
|
||||
expect(user.password_digest).toBeDefined();
|
||||
expect(user.password_digest).toBe(userData.password_digest);
|
||||
expect(user.appearance).toBe('light');
|
||||
expect(user.language).toBe('en');
|
||||
expect(user.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
it('should require email', async () => {
|
||||
const userData = {
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require valid email format', async () => {
|
||||
const userData = {
|
||||
email: 'invalid-email',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should require unique email', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
};
|
||||
|
||||
await User.create(userData);
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate appearance values', async () => {
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
appearance: 'invalid',
|
||||
};
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate task_summary_frequency values', async () => {
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
task_summary_frequency: 'invalid',
|
||||
};
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should require email', async () => {
|
||||
const userData = {
|
||||
password: 'password123'
|
||||
};
|
||||
describe('password methods', () => {
|
||||
let user;
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
});
|
||||
|
||||
it('should hash password on creation', async () => {
|
||||
expect(user.password_digest).toBeDefined();
|
||||
expect(user.password_digest).not.toBe('password123');
|
||||
});
|
||||
|
||||
it('should check password correctly', async () => {
|
||||
const isValid = await User.checkPassword(
|
||||
'password123',
|
||||
user.password_digest
|
||||
);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
const isInvalid = await User.checkPassword(
|
||||
'wrongpassword',
|
||||
user.password_digest
|
||||
);
|
||||
expect(isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it('should set new password using setPassword method', async () => {
|
||||
const oldPasswordDigest = user.password_digest;
|
||||
const newPasswordDigest = await User.hashPassword('newpassword');
|
||||
user.password_digest = newPasswordDigest;
|
||||
await user.save();
|
||||
|
||||
expect(user.password_digest).not.toBe(oldPasswordDigest);
|
||||
|
||||
const isValidNew = await User.checkPassword(
|
||||
'newpassword',
|
||||
user.password_digest
|
||||
);
|
||||
expect(isValidNew).toBe(true);
|
||||
|
||||
const isValidOld = await User.checkPassword(
|
||||
'password123',
|
||||
user.password_digest
|
||||
);
|
||||
expect(isValidOld).toBe(false);
|
||||
});
|
||||
|
||||
it('should hash password on update', async () => {
|
||||
const oldPasswordDigest = user.password_digest;
|
||||
user.password = 'newpassword';
|
||||
await user.save();
|
||||
|
||||
expect(user.password_digest).not.toBe(oldPasswordDigest);
|
||||
|
||||
const isValidNew = await User.checkPassword(
|
||||
'newpassword',
|
||||
user.password_digest
|
||||
);
|
||||
expect(isValidNew).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should require valid email format', async () => {
|
||||
const userData = {
|
||||
email: 'invalid-email',
|
||||
password: 'password123'
|
||||
};
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
});
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
expect(user.appearance).toBe('light');
|
||||
expect(user.language).toBe('en');
|
||||
expect(user.timezone).toBe('UTC');
|
||||
expect(user.task_summary_enabled).toBe(false);
|
||||
expect(user.task_summary_frequency).toBe('daily');
|
||||
});
|
||||
});
|
||||
|
||||
it('should require unique email', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
};
|
||||
|
||||
await User.create(userData);
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate appearance values', async () => {
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
appearance: 'invalid'
|
||||
};
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should validate task_summary_frequency values', async () => {
|
||||
const userData = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
task_summary_frequency: 'invalid'
|
||||
};
|
||||
|
||||
await expect(User.create(userData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('password methods', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
});
|
||||
|
||||
it('should hash password on creation', async () => {
|
||||
expect(user.password_digest).toBeDefined();
|
||||
expect(user.password_digest).not.toBe('password123');
|
||||
});
|
||||
|
||||
it('should check password correctly', async () => {
|
||||
const isValid = await User.checkPassword('password123', user.password_digest);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
const isInvalid = await User.checkPassword('wrongpassword', user.password_digest);
|
||||
expect(isInvalid).toBe(false);
|
||||
});
|
||||
|
||||
it('should set new password using setPassword method', async () => {
|
||||
const oldPasswordDigest = user.password_digest;
|
||||
const newPasswordDigest = await User.hashPassword('newpassword');
|
||||
user.password_digest = newPasswordDigest;
|
||||
await user.save();
|
||||
|
||||
expect(user.password_digest).not.toBe(oldPasswordDigest);
|
||||
|
||||
const isValidNew = await User.checkPassword('newpassword', user.password_digest);
|
||||
expect(isValidNew).toBe(true);
|
||||
|
||||
const isValidOld = await User.checkPassword('password123', user.password_digest);
|
||||
expect(isValidOld).toBe(false);
|
||||
});
|
||||
|
||||
it('should hash password on update', async () => {
|
||||
const oldPasswordDigest = user.password_digest;
|
||||
user.password = 'newpassword';
|
||||
await user.save();
|
||||
|
||||
expect(user.password_digest).not.toBe(oldPasswordDigest);
|
||||
|
||||
const isValidNew = await User.checkPassword('newpassword', user.password_digest);
|
||||
expect(isValidNew).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should set correct default values', async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
const user = await User.create({
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10)
|
||||
});
|
||||
|
||||
expect(user.appearance).toBe('light');
|
||||
expect(user.language).toBe('en');
|
||||
expect(user.timezone).toBe('UTC');
|
||||
expect(user.task_summary_enabled).toBe(false);
|
||||
expect(user.task_summary_frequency).toBe('daily');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,110 +4,127 @@ const quotesService = require('../../../services/quotesService');
|
|||
const taskSummaryService = require('../../../services/taskSummaryService');
|
||||
|
||||
describe('Functional Services', () => {
|
||||
describe('TaskScheduler', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof taskScheduler.initialize).toBe('function');
|
||||
expect(typeof taskScheduler.stop).toBe('function');
|
||||
expect(typeof taskScheduler.restart).toBe('function');
|
||||
expect(typeof taskScheduler.getStatus).toBe('function');
|
||||
describe('TaskScheduler', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof taskScheduler.initialize).toBe('function');
|
||||
expect(typeof taskScheduler.stop).toBe('function');
|
||||
expect(typeof taskScheduler.restart).toBe('function');
|
||||
expect(typeof taskScheduler.getStatus).toBe('function');
|
||||
});
|
||||
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof taskScheduler._createSchedulerState).toBe('function');
|
||||
expect(typeof taskScheduler._shouldDisableScheduler).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof taskScheduler._getCronExpression).toBe('function');
|
||||
});
|
||||
|
||||
it('should return proper cron expressions', () => {
|
||||
expect(taskScheduler._getCronExpression('daily')).toBe('0 7 * * *');
|
||||
expect(taskScheduler._getCronExpression('weekly')).toBe(
|
||||
'0 7 * * 1'
|
||||
);
|
||||
expect(taskScheduler._getCronExpression('1h')).toBe('0 * * * *');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof taskScheduler._createSchedulerState).toBe('function');
|
||||
expect(typeof taskScheduler._shouldDisableScheduler).toBe('function');
|
||||
expect(typeof taskScheduler._getCronExpression).toBe('function');
|
||||
describe('TelegramPoller', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof telegramPoller.addUser).toBe('function');
|
||||
expect(typeof telegramPoller.removeUser).toBe('function');
|
||||
expect(typeof telegramPoller.getStatus).toBe('function');
|
||||
expect(typeof telegramPoller.sendTelegramMessage).toBe('function');
|
||||
});
|
||||
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof telegramPoller._userExistsInList).toBe('function');
|
||||
expect(typeof telegramPoller._addUserToList).toBe('function');
|
||||
expect(typeof telegramPoller._removeUserFromList).toBe('function');
|
||||
expect(typeof telegramPoller._createMessageParams).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle user list operations functionally', () => {
|
||||
const users = [{ id: 1 }, { id: 2 }];
|
||||
const newUser = { id: 3 };
|
||||
|
||||
expect(telegramPoller._userExistsInList(users, 1)).toBe(true);
|
||||
expect(telegramPoller._userExistsInList(users, 3)).toBe(false);
|
||||
|
||||
const updatedUsers = telegramPoller._addUserToList(users, newUser);
|
||||
expect(updatedUsers).toHaveLength(3);
|
||||
expect(users).toHaveLength(2); // Original array unchanged
|
||||
|
||||
const filteredUsers = telegramPoller._removeUserFromList(
|
||||
updatedUsers,
|
||||
2
|
||||
);
|
||||
expect(filteredUsers).toHaveLength(2);
|
||||
expect(filteredUsers.find((u) => u.id === 2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return proper cron expressions', () => {
|
||||
expect(taskScheduler._getCronExpression('daily')).toBe('0 7 * * *');
|
||||
expect(taskScheduler._getCronExpression('weekly')).toBe('0 7 * * 1');
|
||||
expect(taskScheduler._getCronExpression('1h')).toBe('0 * * * *');
|
||||
});
|
||||
});
|
||||
describe('QuotesService', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof quotesService.getRandomQuote).toBe('function');
|
||||
expect(typeof quotesService.getAllQuotes).toBe('function');
|
||||
expect(typeof quotesService.getQuotesCount).toBe('function');
|
||||
expect(typeof quotesService.reloadQuotes).toBe('function');
|
||||
});
|
||||
|
||||
describe('TelegramPoller', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof telegramPoller.addUser).toBe('function');
|
||||
expect(typeof telegramPoller.removeUser).toBe('function');
|
||||
expect(typeof telegramPoller.getStatus).toBe('function');
|
||||
expect(typeof telegramPoller.sendTelegramMessage).toBe('function');
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof quotesService._createDefaultQuotes).toBe('function');
|
||||
expect(typeof quotesService._getRandomIndex).toBe('function');
|
||||
expect(typeof quotesService._validateQuotesData).toBe('function');
|
||||
});
|
||||
|
||||
it('should validate quotes data structure correctly', () => {
|
||||
const validData = { quotes: ['quote1', 'quote2'] };
|
||||
const invalidData1 = { quotes: 'not-array' };
|
||||
const invalidData2 = { notQuotes: ['quote1'] };
|
||||
const invalidData3 = null;
|
||||
|
||||
expect(quotesService._validateQuotesData(validData)).toBe(true);
|
||||
expect(quotesService._validateQuotesData(invalidData1)).toBe(false);
|
||||
expect(quotesService._validateQuotesData(invalidData2)).toBe(false);
|
||||
expect(quotesService._validateQuotesData(invalidData3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof telegramPoller._userExistsInList).toBe('function');
|
||||
expect(typeof telegramPoller._addUserToList).toBe('function');
|
||||
expect(typeof telegramPoller._removeUserFromList).toBe('function');
|
||||
expect(typeof telegramPoller._createMessageParams).toBe('function');
|
||||
});
|
||||
describe('TaskSummaryService', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof taskSummaryService.generateSummaryForUser).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof taskSummaryService.sendSummaryToUser).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof taskSummaryService.calculateNextRunTime).toBe(
|
||||
'function'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle user list operations functionally', () => {
|
||||
const users = [{ id: 1 }, { id: 2 }];
|
||||
const newUser = { id: 3 };
|
||||
|
||||
expect(telegramPoller._userExistsInList(users, 1)).toBe(true);
|
||||
expect(telegramPoller._userExistsInList(users, 3)).toBe(false);
|
||||
|
||||
const updatedUsers = telegramPoller._addUserToList(users, newUser);
|
||||
expect(updatedUsers).toHaveLength(3);
|
||||
expect(users).toHaveLength(2); // Original array unchanged
|
||||
|
||||
const filteredUsers = telegramPoller._removeUserFromList(updatedUsers, 2);
|
||||
expect(filteredUsers).toHaveLength(2);
|
||||
expect(filteredUsers.find(u => u.id === 2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof taskSummaryService._escapeMarkdown).toBe('function');
|
||||
expect(typeof taskSummaryService._getPriorityEmoji).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof taskSummaryService._buildTaskSection).toBe(
|
||||
'function'
|
||||
);
|
||||
});
|
||||
|
||||
describe('QuotesService', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof quotesService.getRandomQuote).toBe('function');
|
||||
expect(typeof quotesService.getAllQuotes).toBe('function');
|
||||
expect(typeof quotesService.getQuotesCount).toBe('function');
|
||||
expect(typeof quotesService.reloadQuotes).toBe('function');
|
||||
});
|
||||
it('should escape markdown correctly', () => {
|
||||
const text = 'Task with *bold* and _italic_ text';
|
||||
const escaped = taskSummaryService._escapeMarkdown(text);
|
||||
expect(escaped).toBe('Task with \\*bold\\* and \\_italic\\_ text');
|
||||
});
|
||||
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof quotesService._createDefaultQuotes).toBe('function');
|
||||
expect(typeof quotesService._getRandomIndex).toBe('function');
|
||||
expect(typeof quotesService._validateQuotesData).toBe('function');
|
||||
it('should return correct priority emojis', () => {
|
||||
expect(taskSummaryService._getPriorityEmoji(0)).toBe('🟢'); // low
|
||||
expect(taskSummaryService._getPriorityEmoji(1)).toBe('🟠'); // medium
|
||||
expect(taskSummaryService._getPriorityEmoji(2)).toBe('🔴'); // high
|
||||
expect(taskSummaryService._getPriorityEmoji(99)).toBe('⚪'); // unknown
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate quotes data structure correctly', () => {
|
||||
const validData = { quotes: ['quote1', 'quote2'] };
|
||||
const invalidData1 = { quotes: 'not-array' };
|
||||
const invalidData2 = { notQuotes: ['quote1'] };
|
||||
const invalidData3 = null;
|
||||
|
||||
expect(quotesService._validateQuotesData(validData)).toBe(true);
|
||||
expect(quotesService._validateQuotesData(invalidData1)).toBe(false);
|
||||
expect(quotesService._validateQuotesData(invalidData2)).toBe(false);
|
||||
expect(quotesService._validateQuotesData(invalidData3)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskSummaryService', () => {
|
||||
it('should export functional interface', () => {
|
||||
expect(typeof taskSummaryService.generateSummaryForUser).toBe('function');
|
||||
expect(typeof taskSummaryService.sendSummaryToUser).toBe('function');
|
||||
expect(typeof taskSummaryService.calculateNextRunTime).toBe('function');
|
||||
});
|
||||
|
||||
it('should have pure helper functions for testing', () => {
|
||||
expect(typeof taskSummaryService._escapeMarkdown).toBe('function');
|
||||
expect(typeof taskSummaryService._getPriorityEmoji).toBe('function');
|
||||
expect(typeof taskSummaryService._buildTaskSection).toBe('function');
|
||||
});
|
||||
|
||||
it('should escape markdown correctly', () => {
|
||||
const text = 'Task with *bold* and _italic_ text';
|
||||
const escaped = taskSummaryService._escapeMarkdown(text);
|
||||
expect(escaped).toBe('Task with \\*bold\\* and \\_italic\\_ text');
|
||||
});
|
||||
|
||||
it('should return correct priority emojis', () => {
|
||||
expect(taskSummaryService._getPriorityEmoji(0)).toBe('🟢'); // low
|
||||
expect(taskSummaryService._getPriorityEmoji(1)).toBe('🟠'); // medium
|
||||
expect(taskSummaryService._getPriorityEmoji(2)).toBe('🔴'); // high
|
||||
expect(taskSummaryService._getPriorityEmoji(99)).toBe('⚪'); // unknown
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,424 +2,550 @@ const RecurringTaskService = require('../../../services/recurringTaskService');
|
|||
const { Task } = require('../../../models');
|
||||
|
||||
describe('RecurringTaskService', () => {
|
||||
describe('Date Calculation Tests', () => {
|
||||
describe('calculateNextDueDate', () => {
|
||||
// Test daily recurrence
|
||||
describe('Daily recurrence', () => {
|
||||
it('should calculate next daily occurrence correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
|
||||
describe('Date Calculation Tests', () => {
|
||||
describe('calculateNextDueDate', () => {
|
||||
// Test daily recurrence
|
||||
describe('Daily recurrence', () => {
|
||||
it('should calculate next daily occurrence correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle custom daily intervals', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 3,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-18T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle edge case with zero interval', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 0,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test weekly recurrence
|
||||
describe('Weekly recurrence', () => {
|
||||
it('should calculate next weekly occurrence correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-22T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle weekly with specific weekday', () => {
|
||||
const task = {
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 1, // Monday
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-20T10:00:00Z')); // Next Monday
|
||||
});
|
||||
|
||||
it('should handle bi-weekly recurrence', () => {
|
||||
const task = {
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 2,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-29T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test monthly recurrence
|
||||
describe('Monthly recurrence', () => {
|
||||
it('should calculate next monthly occurrence correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-02-15T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle month boundaries correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-01-31T10:00:00Z'); // January 31st
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
// February only has 28 days in 2025, should go to Feb 28
|
||||
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle leap year correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2024-01-29T10:00:00Z'); // 2024 is a leap year
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle custom monthly intervals', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 3,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-04-15T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle monthly with specific day', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_month_day: 5,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-02-05T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test monthly weekday recurrence
|
||||
describe('Monthly weekday recurrence', () => {
|
||||
it('should calculate first Monday of month correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_weekday',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 1, // Monday
|
||||
recurrence_week_of_month: 1, // First week
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
// First Monday of February 2025 is February 3rd
|
||||
expect(nextDate).toEqual(new Date('2025-02-03T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should calculate last Friday of month correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_weekday',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 5, // Friday
|
||||
recurrence_week_of_month: 5, // Last week (represented as 5)
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
// Last Friday of February 2025 is February 28th
|
||||
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle third Wednesday of month', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_weekday',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 3, // Wednesday
|
||||
recurrence_week_of_month: 3, // Third week
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
// Third Wednesday of February 2025 is February 19th
|
||||
expect(nextDate).toEqual(new Date('2025-02-19T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test monthly last day recurrence
|
||||
describe('Monthly last day recurrence', () => {
|
||||
it('should calculate last day of month correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_last_day',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
// Last day of February 2025 is February 28th
|
||||
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle leap year last day correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_last_day',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2024-01-15T10:00:00Z'); // 2024 is a leap year
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
// Last day of February 2024 is February 29th
|
||||
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle different month lengths', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_last_day',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-04-15T10:00:00Z'); // April has 30 days
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
// Last day of May 2025 is May 31st
|
||||
expect(nextDate).toEqual(new Date('2025-05-31T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test edge cases and invalid inputs
|
||||
describe('Edge cases and invalid inputs', () => {
|
||||
it('should return null for unsupported recurrence type', () => {
|
||||
const task = {
|
||||
recurrence_type: 'invalid_type',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for none recurrence type', () => {
|
||||
const task = {
|
||||
recurrence_type: 'none',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle invalid date inputs gracefully', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
};
|
||||
const fromDate = new Date('invalid-date');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing task properties', () => {
|
||||
const task = {}; // No recurrence properties
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(
|
||||
task,
|
||||
fromDate
|
||||
);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom daily intervals', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 3
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-18T10:00:00Z'));
|
||||
});
|
||||
describe('Helper Functions', () => {
|
||||
describe('_getFirstWeekdayOfMonth', () => {
|
||||
it('should find first Monday of January 2025', () => {
|
||||
const date = RecurringTaskService._getFirstWeekdayOfMonth(
|
||||
2025,
|
||||
0,
|
||||
1
|
||||
); // January, Monday
|
||||
expect(date.getDate()).toBe(6); // January 6, 2025 is the first Monday
|
||||
});
|
||||
|
||||
it('should handle edge case with zero interval', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 0
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
it('should find first Sunday of February 2025', () => {
|
||||
const date = RecurringTaskService._getFirstWeekdayOfMonth(
|
||||
2025,
|
||||
1,
|
||||
0
|
||||
); // February, Sunday
|
||||
expect(date.getDate()).toBe(2); // February 2, 2025 is the first Sunday
|
||||
});
|
||||
});
|
||||
|
||||
// Test weekly recurrence
|
||||
describe('Weekly recurrence', () => {
|
||||
it('should calculate next weekly occurrence correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-22T10:00:00Z'));
|
||||
});
|
||||
describe('_getLastWeekdayOfMonth', () => {
|
||||
it('should find last Friday of January 2025', () => {
|
||||
const date = RecurringTaskService._getLastWeekdayOfMonth(
|
||||
2025,
|
||||
0,
|
||||
5
|
||||
); // January, Friday
|
||||
expect(date.getDate()).toBe(31); // January 31, 2025 is the last Friday
|
||||
});
|
||||
|
||||
it('should handle weekly with specific weekday', () => {
|
||||
const task = {
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 1 // Monday
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-20T10:00:00Z')); // Next Monday
|
||||
});
|
||||
it('should find last Monday of February 2025', () => {
|
||||
const date = RecurringTaskService._getLastWeekdayOfMonth(
|
||||
2025,
|
||||
1,
|
||||
1
|
||||
); // February, Monday
|
||||
expect(date.getDate()).toBe(24); // February 24, 2025 is the last Monday
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle bi-weekly recurrence', () => {
|
||||
const task = {
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 2
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-01-29T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
describe('_getNthWeekdayOfMonth', () => {
|
||||
it('should find second Tuesday of March 2025', () => {
|
||||
const date = RecurringTaskService._getNthWeekdayOfMonth(
|
||||
2025,
|
||||
2,
|
||||
2,
|
||||
2
|
||||
); // March, Tuesday, 2nd
|
||||
expect(date.getDate()).toBe(11); // March 11, 2025 is the second Tuesday
|
||||
});
|
||||
|
||||
// Test monthly recurrence
|
||||
describe('Monthly recurrence', () => {
|
||||
it('should calculate next monthly occurrence correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-02-15T10:00:00Z'));
|
||||
it('should find fourth Thursday of April 2025', () => {
|
||||
const date = RecurringTaskService._getNthWeekdayOfMonth(
|
||||
2025,
|
||||
3,
|
||||
4,
|
||||
4
|
||||
); // April, Thursday, 4th
|
||||
expect(date.getDate()).toBe(24); // April 24, 2025 is the fourth Thursday
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle month boundaries correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-01-31T10:00:00Z'); // January 31st
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
// February only has 28 days in 2025, should go to Feb 28
|
||||
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle leap year correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2024-01-29T10:00:00Z'); // 2024 is a leap year
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle custom monthly intervals', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 3
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-04-15T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle monthly with specific day', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_month_day: 5
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toEqual(new Date('2025-02-05T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test monthly weekday recurrence
|
||||
describe('Monthly weekday recurrence', () => {
|
||||
it('should calculate first Monday of month correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_weekday',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 1, // Monday
|
||||
recurrence_week_of_month: 1 // First week
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
// First Monday of February 2025 is February 3rd
|
||||
expect(nextDate).toEqual(new Date('2025-02-03T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should calculate last Friday of month correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_weekday',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 5, // Friday
|
||||
recurrence_week_of_month: 5 // Last week (represented as 5)
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
// Last Friday of February 2025 is February 28th
|
||||
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle third Wednesday of month', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_weekday',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 3, // Wednesday
|
||||
recurrence_week_of_month: 3 // Third week
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
// Third Wednesday of February 2025 is February 19th
|
||||
expect(nextDate).toEqual(new Date('2025-02-19T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test monthly last day recurrence
|
||||
describe('Monthly last day recurrence', () => {
|
||||
it('should calculate last day of month correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_last_day',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
// Last day of February 2025 is February 28th
|
||||
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle leap year last day correctly', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_last_day',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2024-01-15T10:00:00Z'); // 2024 is a leap year
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
// Last day of February 2024 is February 29th
|
||||
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle different month lengths', () => {
|
||||
const task = {
|
||||
recurrence_type: 'monthly_last_day',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-04-15T10:00:00Z'); // April has 30 days
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
// Last day of May 2025 is May 31st
|
||||
expect(nextDate).toEqual(new Date('2025-05-31T10:00:00Z'));
|
||||
});
|
||||
});
|
||||
|
||||
// Test edge cases and invalid inputs
|
||||
describe('Edge cases and invalid inputs', () => {
|
||||
it('should return null for unsupported recurrence type', () => {
|
||||
const task = {
|
||||
recurrence_type: 'invalid_type',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for none recurrence type', () => {
|
||||
const task = {
|
||||
recurrence_type: 'none',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle invalid date inputs gracefully', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1
|
||||
};
|
||||
const fromDate = new Date('invalid-date');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing task properties', () => {
|
||||
const task = {}; // No recurrence properties
|
||||
const fromDate = new Date('2025-01-15T10:00:00Z');
|
||||
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
|
||||
|
||||
expect(nextDate).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper Functions', () => {
|
||||
describe('_getFirstWeekdayOfMonth', () => {
|
||||
it('should find first Monday of January 2025', () => {
|
||||
const date = RecurringTaskService._getFirstWeekdayOfMonth(2025, 0, 1); // January, Monday
|
||||
expect(date.getDate()).toBe(6); // January 6, 2025 is the first Monday
|
||||
describe('Task Generation Tests', () => {
|
||||
describe('createTaskInstance', () => {
|
||||
it('should create a task instance with correct parent relationship', async () => {
|
||||
const template = {
|
||||
id: 1,
|
||||
name: 'Test Recurring Task',
|
||||
description: 'Test description',
|
||||
priority: 1,
|
||||
note: 'Test note',
|
||||
user_id: 1,
|
||||
project_id: 2,
|
||||
};
|
||||
const dueDate = new Date('2025-01-20T10:00:00Z');
|
||||
|
||||
// Mock Task.create
|
||||
const mockCreate = jest.fn().mockResolvedValue({
|
||||
id: 10,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
due_date: dueDate,
|
||||
priority: template.priority,
|
||||
status: 0, // NOT_STARTED
|
||||
note: template.note,
|
||||
user_id: template.user_id,
|
||||
project_id: template.project_id,
|
||||
recurrence_type: 'none',
|
||||
recurring_parent_id: template.id,
|
||||
});
|
||||
Task.create = mockCreate;
|
||||
|
||||
const result = await RecurringTaskService.createTaskInstance(
|
||||
template,
|
||||
dueDate
|
||||
);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
due_date: dueDate,
|
||||
today: false,
|
||||
priority: template.priority,
|
||||
status: 0, // Task.STATUS.NOT_STARTED
|
||||
note: template.note,
|
||||
user_id: template.user_id,
|
||||
project_id: template.project_id,
|
||||
recurrence_type: 'none',
|
||||
recurring_parent_id: template.id,
|
||||
});
|
||||
|
||||
expect(result.recurring_parent_id).toBe(template.id);
|
||||
expect(result.recurrence_type).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
it('should find first Sunday of February 2025', () => {
|
||||
const date = RecurringTaskService._getFirstWeekdayOfMonth(2025, 1, 0); // February, Sunday
|
||||
expect(date.getDate()).toBe(2); // February 2, 2025 is the first Sunday
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getLastWeekdayOfMonth', () => {
|
||||
it('should find last Friday of January 2025', () => {
|
||||
const date = RecurringTaskService._getLastWeekdayOfMonth(2025, 0, 5); // January, Friday
|
||||
expect(date.getDate()).toBe(31); // January 31, 2025 is the last Friday
|
||||
});
|
||||
|
||||
it('should find last Monday of February 2025', () => {
|
||||
const date = RecurringTaskService._getLastWeekdayOfMonth(2025, 1, 1); // February, Monday
|
||||
expect(date.getDate()).toBe(24); // February 24, 2025 is the last Monday
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getNthWeekdayOfMonth', () => {
|
||||
it('should find second Tuesday of March 2025', () => {
|
||||
const date = RecurringTaskService._getNthWeekdayOfMonth(2025, 2, 2, 2); // March, Tuesday, 2nd
|
||||
expect(date.getDate()).toBe(11); // March 11, 2025 is the second Tuesday
|
||||
});
|
||||
|
||||
it('should find fourth Thursday of April 2025', () => {
|
||||
const date = RecurringTaskService._getNthWeekdayOfMonth(2025, 3, 4, 4); // April, Thursday, 4th
|
||||
expect(date.getDate()).toBe(24); // April 24, 2025 is the fourth Thursday
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Generation Tests', () => {
|
||||
describe('createTaskInstance', () => {
|
||||
it('should create a task instance with correct parent relationship', async () => {
|
||||
const template = {
|
||||
id: 1,
|
||||
name: 'Test Recurring Task',
|
||||
description: 'Test description',
|
||||
priority: 1,
|
||||
note: 'Test note',
|
||||
user_id: 1,
|
||||
project_id: 2
|
||||
};
|
||||
const dueDate = new Date('2025-01-20T10:00:00Z');
|
||||
|
||||
// Mock Task.create
|
||||
const mockCreate = jest.fn().mockResolvedValue({
|
||||
id: 10,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
due_date: dueDate,
|
||||
priority: template.priority,
|
||||
status: 0, // NOT_STARTED
|
||||
note: template.note,
|
||||
user_id: template.user_id,
|
||||
project_id: template.project_id,
|
||||
recurrence_type: 'none',
|
||||
recurring_parent_id: template.id
|
||||
});
|
||||
Task.create = mockCreate;
|
||||
|
||||
const result = await RecurringTaskService.createTaskInstance(template, dueDate);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
due_date: dueDate,
|
||||
today: false,
|
||||
priority: template.priority,
|
||||
status: 0, // Task.STATUS.NOT_STARTED
|
||||
note: template.note,
|
||||
user_id: template.user_id,
|
||||
project_id: template.project_id,
|
||||
recurrence_type: 'none',
|
||||
recurring_parent_id: template.id
|
||||
});
|
||||
|
||||
expect(result.recurring_parent_id).toBe(template.id);
|
||||
expect(result.recurrence_type).toBe('none');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('End Date Validation', () => {
|
||||
describe('shouldGenerateNextTask', () => {
|
||||
it('should generate task when no end date is set', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: null
|
||||
};
|
||||
const nextDate = new Date('2025-12-31T10:00:00Z');
|
||||
|
||||
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
|
||||
expect(shouldGenerate).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate task when next date is before end date', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: new Date('2025-12-31T10:00:00Z')
|
||||
};
|
||||
const nextDate = new Date('2025-06-15T10:00:00Z');
|
||||
|
||||
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
|
||||
expect(shouldGenerate).toBe(true);
|
||||
});
|
||||
|
||||
it('should not generate task when next date is after end date', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: new Date('2025-06-15T10:00:00Z')
|
||||
};
|
||||
const nextDate = new Date('2025-12-31T10:00:00Z');
|
||||
|
||||
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
|
||||
expect(shouldGenerate).toBe(false);
|
||||
});
|
||||
|
||||
it('should not generate task when next date equals end date', () => {
|
||||
const endDate = new Date('2025-06-15T10:00:00Z');
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: endDate
|
||||
};
|
||||
const nextDate = new Date('2025-06-15T10:00:00Z');
|
||||
|
||||
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
|
||||
expect(shouldGenerate).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Interface', () => {
|
||||
it('should export all required methods', () => {
|
||||
expect(typeof RecurringTaskService.generateRecurringTasks).toBe('function');
|
||||
expect(typeof RecurringTaskService.processRecurringTask).toBe('function');
|
||||
expect(typeof RecurringTaskService.calculateNextDueDate).toBe('function');
|
||||
expect(typeof RecurringTaskService.createTaskInstance).toBe('function');
|
||||
expect(typeof RecurringTaskService.handleTaskCompletion).toBe('function');
|
||||
});
|
||||
|
||||
it('should have helper functions for testing', () => {
|
||||
expect(typeof RecurringTaskService._getFirstWeekdayOfMonth).toBe('function');
|
||||
expect(typeof RecurringTaskService._getLastWeekdayOfMonth).toBe('function');
|
||||
expect(typeof RecurringTaskService._getNthWeekdayOfMonth).toBe('function');
|
||||
expect(typeof RecurringTaskService._shouldGenerateNextTask).toBe('function');
|
||||
describe('End Date Validation', () => {
|
||||
describe('shouldGenerateNextTask', () => {
|
||||
it('should generate task when no end date is set', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: null,
|
||||
};
|
||||
const nextDate = new Date('2025-12-31T10:00:00Z');
|
||||
|
||||
const shouldGenerate =
|
||||
RecurringTaskService._shouldGenerateNextTask(
|
||||
task,
|
||||
nextDate
|
||||
);
|
||||
expect(shouldGenerate).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate task when next date is before end date', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: new Date('2025-12-31T10:00:00Z'),
|
||||
};
|
||||
const nextDate = new Date('2025-06-15T10:00:00Z');
|
||||
|
||||
const shouldGenerate =
|
||||
RecurringTaskService._shouldGenerateNextTask(
|
||||
task,
|
||||
nextDate
|
||||
);
|
||||
expect(shouldGenerate).toBe(true);
|
||||
});
|
||||
|
||||
it('should not generate task when next date is after end date', () => {
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: new Date('2025-06-15T10:00:00Z'),
|
||||
};
|
||||
const nextDate = new Date('2025-12-31T10:00:00Z');
|
||||
|
||||
const shouldGenerate =
|
||||
RecurringTaskService._shouldGenerateNextTask(
|
||||
task,
|
||||
nextDate
|
||||
);
|
||||
expect(shouldGenerate).toBe(false);
|
||||
});
|
||||
|
||||
it('should not generate task when next date equals end date', () => {
|
||||
const endDate = new Date('2025-06-15T10:00:00Z');
|
||||
const task = {
|
||||
recurrence_type: 'daily',
|
||||
recurrence_end_date: endDate,
|
||||
};
|
||||
const nextDate = new Date('2025-06-15T10:00:00Z');
|
||||
|
||||
const shouldGenerate =
|
||||
RecurringTaskService._shouldGenerateNextTask(
|
||||
task,
|
||||
nextDate
|
||||
);
|
||||
expect(shouldGenerate).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Interface', () => {
|
||||
it('should export all required methods', () => {
|
||||
expect(typeof RecurringTaskService.generateRecurringTasks).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof RecurringTaskService.processRecurringTask).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof RecurringTaskService.calculateNextDueDate).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof RecurringTaskService.createTaskInstance).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof RecurringTaskService.handleTaskCompletion).toBe(
|
||||
'function'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have helper functions for testing', () => {
|
||||
expect(typeof RecurringTaskService._getFirstWeekdayOfMonth).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof RecurringTaskService._getLastWeekdayOfMonth).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof RecurringTaskService._getNthWeekdayOfMonth).toBe(
|
||||
'function'
|
||||
);
|
||||
expect(typeof RecurringTaskService._shouldGenerateNextTask).toBe(
|
||||
'function'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,170 +3,204 @@ const telegramPoller = require('../../../services/telegramPoller');
|
|||
|
||||
// Mock the database models
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
update: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn()
|
||||
},
|
||||
InboxItem: {
|
||||
create: jest.fn(),
|
||||
findOne: jest.fn()
|
||||
}
|
||||
User: {
|
||||
update: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
InboxItem: {
|
||||
create: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock https module
|
||||
jest.mock('https', () => ({
|
||||
get: jest.fn(),
|
||||
request: jest.fn()
|
||||
get: jest.fn(),
|
||||
request: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('TelegramPoller Duplicate Prevention', () => {
|
||||
let mockUser;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUser = {
|
||||
id: 1,
|
||||
telegram_bot_token: 'test-token',
|
||||
telegram_chat_id: '123456789'
|
||||
};
|
||||
|
||||
// Reset poller state
|
||||
telegramPoller.stopPolling();
|
||||
});
|
||||
let mockUser;
|
||||
|
||||
describe('Update ID Tracking', () => {
|
||||
test('should filter out already processed updates', () => {
|
||||
const updates = [
|
||||
{ update_id: 100, message: { text: 'Hello 1', message_id: 1, chat: { id: 123 } } },
|
||||
{ update_id: 101, message: { text: 'Hello 2', message_id: 2, chat: { id: 123 } } },
|
||||
{ update_id: 102, message: { text: 'Hello 3', message_id: 3, chat: { id: 123 } } }
|
||||
];
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Test internal function for filtering
|
||||
const processedUpdates = new Set(['1-100', '1-101']);
|
||||
const newUpdates = updates.filter(update => {
|
||||
const updateKey = `1-${update.update_id}`;
|
||||
return !processedUpdates.has(updateKey);
|
||||
});
|
||||
mockUser = {
|
||||
id: 1,
|
||||
telegram_bot_token: 'test-token',
|
||||
telegram_chat_id: '123456789',
|
||||
};
|
||||
|
||||
expect(newUpdates).toHaveLength(1);
|
||||
expect(newUpdates[0].update_id).toBe(102);
|
||||
// Reset poller state
|
||||
telegramPoller.stopPolling();
|
||||
});
|
||||
|
||||
test('should track highest update ID correctly', () => {
|
||||
const updates = [
|
||||
{ update_id: 98 },
|
||||
{ update_id: 101 },
|
||||
{ update_id: 99 }
|
||||
];
|
||||
describe('Update ID Tracking', () => {
|
||||
test('should filter out already processed updates', () => {
|
||||
const updates = [
|
||||
{
|
||||
update_id: 100,
|
||||
message: {
|
||||
text: 'Hello 1',
|
||||
message_id: 1,
|
||||
chat: { id: 123 },
|
||||
},
|
||||
},
|
||||
{
|
||||
update_id: 101,
|
||||
message: {
|
||||
text: 'Hello 2',
|
||||
message_id: 2,
|
||||
chat: { id: 123 },
|
||||
},
|
||||
},
|
||||
{
|
||||
update_id: 102,
|
||||
message: {
|
||||
text: 'Hello 3',
|
||||
message_id: 3,
|
||||
chat: { id: 123 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const highestUpdateId = telegramPoller._getHighestUpdateId(updates);
|
||||
expect(highestUpdateId).toBe(101);
|
||||
// Test internal function for filtering
|
||||
const processedUpdates = new Set(['1-100', '1-101']);
|
||||
const newUpdates = updates.filter((update) => {
|
||||
const updateKey = `1-${update.update_id}`;
|
||||
return !processedUpdates.has(updateKey);
|
||||
});
|
||||
|
||||
expect(newUpdates).toHaveLength(1);
|
||||
expect(newUpdates[0].update_id).toBe(102);
|
||||
});
|
||||
|
||||
test('should track highest update ID correctly', () => {
|
||||
const updates = [
|
||||
{ update_id: 98 },
|
||||
{ update_id: 101 },
|
||||
{ update_id: 99 },
|
||||
];
|
||||
|
||||
const highestUpdateId = telegramPoller._getHighestUpdateId(updates);
|
||||
expect(highestUpdateId).toBe(101);
|
||||
});
|
||||
|
||||
test('should handle empty updates array', () => {
|
||||
const highestUpdateId = telegramPoller._getHighestUpdateId([]);
|
||||
expect(highestUpdateId).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle empty updates array', () => {
|
||||
const highestUpdateId = telegramPoller._getHighestUpdateId([]);
|
||||
expect(highestUpdateId).toBe(0);
|
||||
});
|
||||
});
|
||||
describe('User List Management', () => {
|
||||
test('should not add duplicate users', () => {
|
||||
const users = [{ id: 1, name: 'User 1' }];
|
||||
const newUser = { id: 1, name: 'User 1 Updated' };
|
||||
|
||||
describe('User List Management', () => {
|
||||
test('should not add duplicate users', () => {
|
||||
const users = [{ id: 1, name: 'User 1' }];
|
||||
const newUser = { id: 1, name: 'User 1 Updated' };
|
||||
|
||||
const userExists = telegramPoller._userExistsInList(users, 1);
|
||||
expect(userExists).toBe(true);
|
||||
|
||||
const updatedUsers = telegramPoller._addUserToList(users, newUser);
|
||||
expect(updatedUsers).toHaveLength(1);
|
||||
expect(updatedUsers).toEqual(users); // Should return original array unchanged
|
||||
const userExists = telegramPoller._userExistsInList(users, 1);
|
||||
expect(userExists).toBe(true);
|
||||
|
||||
const updatedUsers = telegramPoller._addUserToList(users, newUser);
|
||||
expect(updatedUsers).toHaveLength(1);
|
||||
expect(updatedUsers).toEqual(users); // Should return original array unchanged
|
||||
});
|
||||
|
||||
test('should add new users correctly', () => {
|
||||
const users = [{ id: 1, name: 'User 1' }];
|
||||
const newUser = { id: 2, name: 'User 2' };
|
||||
|
||||
const userExists = telegramPoller._userExistsInList(users, 2);
|
||||
expect(userExists).toBe(false);
|
||||
|
||||
const updatedUsers = telegramPoller._addUserToList(users, newUser);
|
||||
expect(updatedUsers).toHaveLength(2);
|
||||
expect(updatedUsers).toContain(newUser);
|
||||
});
|
||||
|
||||
test('should remove users correctly', () => {
|
||||
const users = [
|
||||
{ id: 1, name: 'User 1' },
|
||||
{ id: 2, name: 'User 2' },
|
||||
{ id: 3, name: 'User 3' },
|
||||
];
|
||||
|
||||
const updatedUsers = telegramPoller._removeUserFromList(users, 2);
|
||||
expect(updatedUsers).toHaveLength(2);
|
||||
expect(updatedUsers.find((u) => u.id === 2)).toBeUndefined();
|
||||
expect(updatedUsers.find((u) => u.id === 1)).toBeDefined();
|
||||
expect(updatedUsers.find((u) => u.id === 3)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should add new users correctly', () => {
|
||||
const users = [{ id: 1, name: 'User 1' }];
|
||||
const newUser = { id: 2, name: 'User 2' };
|
||||
|
||||
const userExists = telegramPoller._userExistsInList(users, 2);
|
||||
expect(userExists).toBe(false);
|
||||
|
||||
const updatedUsers = telegramPoller._addUserToList(users, newUser);
|
||||
expect(updatedUsers).toHaveLength(2);
|
||||
expect(updatedUsers).toContain(newUser);
|
||||
describe('Message Parameters', () => {
|
||||
test('should create message parameters without reply', () => {
|
||||
const params = telegramPoller._createMessageParams(
|
||||
'123',
|
||||
'Hello World'
|
||||
);
|
||||
expect(params).toEqual({
|
||||
chat_id: '123',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
test('should create message parameters with reply', () => {
|
||||
const params = telegramPoller._createMessageParams(
|
||||
'123',
|
||||
'Hello World',
|
||||
456
|
||||
);
|
||||
expect(params).toEqual({
|
||||
chat_id: '123',
|
||||
text: 'Hello World',
|
||||
reply_to_message_id: 456,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should remove users correctly', () => {
|
||||
const users = [
|
||||
{ id: 1, name: 'User 1' },
|
||||
{ id: 2, name: 'User 2' },
|
||||
{ id: 3, name: 'User 3' }
|
||||
];
|
||||
|
||||
const updatedUsers = telegramPoller._removeUserFromList(users, 2);
|
||||
expect(updatedUsers).toHaveLength(2);
|
||||
expect(updatedUsers.find(u => u.id === 2)).toBeUndefined();
|
||||
expect(updatedUsers.find(u => u.id === 1)).toBeDefined();
|
||||
expect(updatedUsers.find(u => u.id === 3)).toBeDefined();
|
||||
});
|
||||
});
|
||||
describe('Telegram URL Creation', () => {
|
||||
test('should create URL without parameters', () => {
|
||||
const url = telegramPoller._createTelegramUrl('token123', 'getMe');
|
||||
expect(url).toBe('https://api.telegram.org/bottoken123/getMe');
|
||||
});
|
||||
|
||||
describe('Message Parameters', () => {
|
||||
test('should create message parameters without reply', () => {
|
||||
const params = telegramPoller._createMessageParams('123', 'Hello World');
|
||||
expect(params).toEqual({
|
||||
chat_id: '123',
|
||||
text: 'Hello World'
|
||||
});
|
||||
test('should create URL with parameters', () => {
|
||||
const url = telegramPoller._createTelegramUrl(
|
||||
'token123',
|
||||
'getUpdates',
|
||||
{
|
||||
offset: '100',
|
||||
timeout: '30',
|
||||
}
|
||||
);
|
||||
expect(url).toBe(
|
||||
'https://api.telegram.org/bottoken123/getUpdates?offset=100&timeout=30'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should create message parameters with reply', () => {
|
||||
const params = telegramPoller._createMessageParams('123', 'Hello World', 456);
|
||||
expect(params).toEqual({
|
||||
chat_id: '123',
|
||||
text: 'Hello World',
|
||||
reply_to_message_id: 456
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('State Management', () => {
|
||||
test('should return correct initial state', () => {
|
||||
const state = telegramPoller._createPollerState();
|
||||
expect(state).toEqual({
|
||||
running: false,
|
||||
interval: null,
|
||||
pollInterval: 5000,
|
||||
usersToPool: [],
|
||||
userStatus: {},
|
||||
processedUpdates: expect.any(Set),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Telegram URL Creation', () => {
|
||||
test('should create URL without parameters', () => {
|
||||
const url = telegramPoller._createTelegramUrl('token123', 'getMe');
|
||||
expect(url).toBe('https://api.telegram.org/bottoken123/getMe');
|
||||
test('should track poller status correctly', () => {
|
||||
const status = telegramPoller.getStatus();
|
||||
expect(status).toEqual({
|
||||
running: false,
|
||||
usersCount: 0,
|
||||
pollInterval: 5000,
|
||||
userStatus: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should create URL with parameters', () => {
|
||||
const url = telegramPoller._createTelegramUrl('token123', 'getUpdates', {
|
||||
offset: '100',
|
||||
timeout: '30'
|
||||
});
|
||||
expect(url).toBe('https://api.telegram.org/bottoken123/getUpdates?offset=100&timeout=30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Management', () => {
|
||||
test('should return correct initial state', () => {
|
||||
const state = telegramPoller._createPollerState();
|
||||
expect(state).toEqual({
|
||||
running: false,
|
||||
interval: null,
|
||||
pollInterval: 5000,
|
||||
usersToPool: [],
|
||||
userStatus: {},
|
||||
processedUpdates: expect.any(Set)
|
||||
});
|
||||
});
|
||||
|
||||
test('should track poller status correctly', () => {
|
||||
const status = telegramPoller.getStatus();
|
||||
expect(status).toEqual({
|
||||
running: false,
|
||||
usersCount: 0,
|
||||
pollInterval: 5000,
|
||||
userStatus: {}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
17090
frontend/package-lock.json
generated
17090
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue