802 lines
30 KiB
Markdown
802 lines
30 KiB
Markdown
# Backend Notifications Module
|
|
|
|
## Overview
|
|
|
|
The Notifications module provides a centralized **error handling system** and **internationalization (i18n) catalog** for the backend. It includes custom error classes with HTTP status codes and a message resolution system that supports parameter substitution.
|
|
|
|
**Location:** `backend/src/services/notifications/`
|
|
|
|
**Total Files:** 4
|
|
|
|
---
|
|
|
|
## Architecture Diagram
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Services Layer │
|
|
│ (auth.js, users.js, projects.js, roles.js, search.js, etc.) │
|
|
│ │
|
|
│ throw new ValidationError('auth.userNotFound'); │
|
|
│ throw new ForbiddenError('auth.forbidden'); │
|
|
└───────────────────────────────┬─────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Notifications Module │
|
|
│ │
|
|
│ ┌─────────────────────────────────────────────────────────────┐ │
|
|
│ │ Error Classes │ │
|
|
│ │ (errors/) │ │
|
|
│ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │
|
|
│ │ │ ValidationError │ │ ForbiddenError │ │ │
|
|
│ │ │ (HTTP 400) │ │ (HTTP 403) │ │ │
|
|
│ │ └──────────┬──────────┘ └──────────┬──────────┘ │ │
|
|
│ │ │ │ │ │
|
|
│ │ └────────────┬───────────┘ │ │
|
|
│ │ │ │ │
|
|
│ │ ▼ │ │
|
|
│ │ ┌─────────────────────────┐ │ │
|
|
│ │ │ helpers.js │ │ │
|
|
│ │ │ • getNotification() │ │ │
|
|
│ │ │ • isNotification() │ │ │
|
|
│ │ └────────────┬────────────┘ │ │
|
|
│ │ │ │ │
|
|
│ │ ▼ │ │
|
|
│ │ ┌─────────────────────────┐ │ │
|
|
│ │ │ list.js │ │ │
|
|
│ │ │ (Message Catalog) │ │ │
|
|
│ │ │ • auth.* │ │ │
|
|
│ │ │ • iam.* │ │ │
|
|
│ │ │ • importer.* │ │ │
|
|
│ │ │ • errors.* │ │ │
|
|
│ │ │ • emails.* │ │ │
|
|
│ │ └─────────────────────────┘ │ │
|
|
│ └─────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Error Handler Middleware │
|
|
│ (Generic error handling in index.js) │
|
|
│ │
|
|
│ app.use((err, req, res, _next) => { │
|
|
│ res.status(err.code || 500).json({ message: err.message }); │
|
|
│ }); │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
services/notifications/
|
|
├── helpers.js # Message resolution functions (31 LOC)
|
|
├── list.js # Message catalog / i18n strings (101 LOC)
|
|
└── errors/
|
|
├── validation.js # ValidationError class - 400 (17 LOC)
|
|
└── forbidden.js # ForbiddenError class - 403 (17 LOC)
|
|
```
|
|
|
|
---
|
|
|
|
## Core Components
|
|
|
|
### Message Catalog (`list.js`)
|
|
|
|
Centralized storage for all user-facing messages and email content.
|
|
|
|
```javascript
|
|
const errors = {
|
|
app: {
|
|
title: 'Tour Builder Platform',
|
|
},
|
|
|
|
auth: {
|
|
userDisabled: 'Your account is disabled',
|
|
forbidden: 'Forbidden',
|
|
unauthorized: 'Unauthorized',
|
|
userNotFound: "Sorry, we don't recognize your credentials",
|
|
wrongPassword: "Sorry, we don't recognize your credentials",
|
|
weakPassword: 'This password is too weak',
|
|
emailAlreadyInUse: 'Email is already in use',
|
|
invalidEmail: 'Please provide a valid email',
|
|
passwordReset: {
|
|
invalidToken: 'Password reset link is invalid or has expired',
|
|
error: 'Email not recognized',
|
|
},
|
|
passwordUpdate: {
|
|
samePassword: "You can't use the same password. Please create new password",
|
|
},
|
|
userNotVerified: 'Sorry, your email has not been verified yet',
|
|
emailAddressVerificationEmail: {
|
|
invalidToken: 'Email verification link is invalid or has expired',
|
|
error: 'Email not recognized',
|
|
},
|
|
},
|
|
|
|
iam: {
|
|
errors: {
|
|
userAlreadyExists: 'User with this email already exists',
|
|
userNotFound: 'User not found',
|
|
disablingHimself: "You can't disable yourself",
|
|
revokingOwnPermission: "You can't revoke your own owner permission",
|
|
deletingHimself: "You can't delete yourself",
|
|
emailRequired: 'Email is required',
|
|
slugAlreadyExists: 'This slug is already in use by another project',
|
|
searchQueryRequired: 'Search query is required',
|
|
},
|
|
},
|
|
|
|
importer: {
|
|
errors: {
|
|
invalidFileEmpty: 'The file is empty',
|
|
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
|
|
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
|
|
importHashRequired: 'Import hash is required',
|
|
importHashExistent: 'Data has already been imported',
|
|
userEmailMissing: 'Some items in the CSV do not have an email',
|
|
},
|
|
},
|
|
|
|
errors: {
|
|
forbidden: {
|
|
message: 'Forbidden',
|
|
},
|
|
validation: {
|
|
message: 'An error occurred',
|
|
},
|
|
searchQueryRequired: {
|
|
message: 'Search query is required',
|
|
},
|
|
},
|
|
|
|
emails: {
|
|
invitation: {
|
|
subject: "You've been invited to {0}",
|
|
body: '...',
|
|
},
|
|
emailAddressVerification: {
|
|
subject: 'Verify your email for {0}',
|
|
body: '...',
|
|
},
|
|
passwordReset: {
|
|
subject: 'Reset your password for {0}',
|
|
body: '...',
|
|
},
|
|
},
|
|
};
|
|
|
|
module.exports = errors;
|
|
```
|
|
|
|
### Message Categories
|
|
|
|
| Category | Purpose | Example Key |
|
|
|----------|---------|-------------|
|
|
| `app` | Application metadata | `app.title` |
|
|
| `auth` | Authentication errors | `auth.userNotFound` |
|
|
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
|
|
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
|
|
| `errors` | Generic error messages | `errors.validation.message` |
|
|
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
|
|
|
|
---
|
|
|
|
### Helper Functions (`helpers.js`)
|
|
|
|
Functions for resolving and formatting notification messages.
|
|
|
|
```javascript
|
|
const _get = require('lodash/get');
|
|
const errors = require('./list');
|
|
|
|
/**
|
|
* Format message with positional arguments
|
|
* @param {string} message - Message with {0}, {1}, etc. placeholders
|
|
* @param {Array} args - Arguments to substitute
|
|
* @returns {string} Formatted message
|
|
*/
|
|
function format(message, args) {
|
|
if (!message) {
|
|
return null;
|
|
}
|
|
|
|
return message.replace(/{(\d+)}/g, function (match, number) {
|
|
return typeof args[number] != 'undefined' ? args[number] : match;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if a key exists in the notification catalog
|
|
* @param {string} key - Dot-notation path (e.g., 'auth.userNotFound')
|
|
* @returns {boolean}
|
|
*/
|
|
const isNotification = (key) => {
|
|
const message = _get(errors, key);
|
|
return !!message;
|
|
};
|
|
|
|
/**
|
|
* Get notification message by key with optional parameter substitution
|
|
* @param {string} key - Dot-notation path
|
|
* @param {...any} args - Values to substitute for {0}, {1}, etc.
|
|
* @returns {string} Resolved message or original key if not found
|
|
*/
|
|
const getNotification = (key, ...args) => {
|
|
const message = _get(errors, key);
|
|
|
|
if (!message) {
|
|
return key; // Return raw key as fallback
|
|
}
|
|
|
|
return format(message, args);
|
|
};
|
|
|
|
exports.getNotification = getNotification;
|
|
exports.isNotification = isNotification;
|
|
```
|
|
|
|
**Usage Examples:**
|
|
|
|
```javascript
|
|
const { getNotification, isNotification } = require('./notifications/helpers');
|
|
|
|
// Simple lookup
|
|
getNotification('auth.userNotFound');
|
|
// → "Sorry, we don't recognize your credentials"
|
|
|
|
// With parameter substitution
|
|
getNotification('emails.invitation.subject', 'Tour Builder Platform');
|
|
// → "You've been invited to Tour Builder Platform"
|
|
|
|
// Check if key exists
|
|
isNotification('auth.userNotFound'); // → true
|
|
isNotification('custom.message'); // → false
|
|
|
|
// Unknown key returns the key itself
|
|
getNotification('unknown.key');
|
|
// → "unknown.key"
|
|
```
|
|
|
|
---
|
|
|
|
### Error Classes
|
|
|
|
#### ValidationError (`errors/validation.js`)
|
|
|
|
HTTP 400 Bad Request error for validation failures.
|
|
|
|
```javascript
|
|
const { getNotification, isNotification } = require('../helpers');
|
|
|
|
module.exports = class ValidationError extends Error {
|
|
constructor(messageCode) {
|
|
let message;
|
|
|
|
// Try to resolve from notification catalog
|
|
if (messageCode && isNotification(messageCode)) {
|
|
message = getNotification(messageCode);
|
|
}
|
|
|
|
// Fallback to generic validation message
|
|
message = message || getNotification('errors.validation.message');
|
|
|
|
super(message);
|
|
this.code = 400;
|
|
}
|
|
};
|
|
```
|
|
|
|
**Properties:**
|
|
- `message` - Human-readable error message
|
|
- `code` - HTTP status code (400)
|
|
|
|
**Usage:**
|
|
```javascript
|
|
const ValidationError = require('./notifications/errors/validation');
|
|
|
|
// With catalog key
|
|
throw new ValidationError('auth.userNotFound');
|
|
// → Error: "Sorry, we don't recognize your credentials" (code: 400)
|
|
|
|
// With unknown key (uses fallback)
|
|
throw new ValidationError('unknown.error');
|
|
// → Error: "An error occurred" (code: 400)
|
|
|
|
// Without argument
|
|
throw new ValidationError();
|
|
// → Error: "An error occurred" (code: 400)
|
|
```
|
|
|
|
#### ForbiddenError (`errors/forbidden.js`)
|
|
|
|
HTTP 403 Forbidden error for authorization failures.
|
|
|
|
```javascript
|
|
const { getNotification, isNotification } = require('../helpers');
|
|
|
|
module.exports = class ForbiddenError extends Error {
|
|
constructor(messageCode) {
|
|
let message;
|
|
|
|
// Try to resolve from notification catalog
|
|
if (messageCode && isNotification(messageCode)) {
|
|
message = getNotification(messageCode);
|
|
}
|
|
|
|
// Fallback to generic forbidden message
|
|
message = message || getNotification('errors.forbidden.message');
|
|
|
|
super(message);
|
|
this.code = 403;
|
|
}
|
|
};
|
|
```
|
|
|
|
**Properties:**
|
|
- `message` - Human-readable error message
|
|
- `code` - HTTP status code (403)
|
|
|
|
**Usage:**
|
|
```javascript
|
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
|
|
|
// With catalog key
|
|
throw new ForbiddenError('auth.forbidden');
|
|
// → Error: "Forbidden" (code: 403)
|
|
|
|
// Without argument
|
|
throw new ForbiddenError();
|
|
// → Error: "Forbidden" (code: 403)
|
|
```
|
|
|
|
---
|
|
|
|
## Complete Message Reference
|
|
|
|
### Authentication Messages (`auth.*`)
|
|
|
|
| Key | Message | Used In |
|
|
|-----|---------|---------|
|
|
| `auth.userDisabled` | "Your account is disabled" | signin |
|
|
| `auth.forbidden` | "Forbidden" | authorization failures |
|
|
| `auth.unauthorized` | "Unauthorized" | missing authentication |
|
|
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
|
|
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
|
|
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
|
|
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
|
|
| `auth.invalidEmail` | "Please provide a valid email" | signup |
|
|
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
|
|
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
|
|
| `auth.passwordReset.error` | "Email not recognized" | password reset request |
|
|
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
|
|
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
|
|
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
|
|
|
|
### IAM Messages (`iam.errors.*`)
|
|
|
|
| Key | Message | Used In |
|
|
|-----|---------|---------|
|
|
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
|
|
| `iam.errors.userNotFound` | "User not found" | user update/delete |
|
|
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
|
|
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
|
|
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
|
|
| `iam.errors.emailRequired` | "Email is required" | user creation |
|
|
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
|
|
| `iam.errors.searchQueryRequired` | "Search query is required" | search |
|
|
|
|
### Importer Messages (`importer.errors.*`)
|
|
|
|
| Key | Message | Used In |
|
|
|-----|---------|---------|
|
|
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
|
|
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
|
|
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
|
|
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
|
|
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
|
|
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
|
|
|
|
### Generic Messages (`errors.*`)
|
|
|
|
| Key | Message | Used In |
|
|
|-----|---------|---------|
|
|
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
|
|
| `errors.validation.message` | "An error occurred" | ValidationError default |
|
|
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
|
|
|
|
### Email Messages (`emails.*`)
|
|
|
|
| Key | Message | Used In |
|
|
|-----|---------|---------|
|
|
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
|
|
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
|
|
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
|
|
|
|
---
|
|
|
|
## Integration Points
|
|
|
|
### Services Using Notifications
|
|
|
|
| Service | Errors Used | Common Keys |
|
|
|---------|-------------|-------------|
|
|
| `auth.js` | ValidationError, ForbiddenError | auth.*, iam.* |
|
|
| `users.ts` | ValidationError | iam.errors.* |
|
|
| `projects.ts` | ValidationError | projectsNotFound |
|
|
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
|
|
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
|
|
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
|
|
|
|
### Email Templates Using Notifications
|
|
|
|
| Template | Helper Usage |
|
|
|----------|--------------|
|
|
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
|
|
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
|
|
| `invitation.js` | `getNotification('emails.invitation.subject')` |
|
|
|
|
### Middleware Using Notifications
|
|
|
|
| Middleware | Error Used | Purpose |
|
|
|------------|------------|---------|
|
|
| `check-permissions.ts` | ValidationError | Permission denied responses |
|
|
|
|
### Service Factory Using Notifications
|
|
|
|
```javascript
|
|
// factories/service.factory.js
|
|
const ValidationError = require('../services/notifications/errors/validation');
|
|
|
|
// Used in update() when entity not found
|
|
if (!record) {
|
|
throw new ValidationError(`${entityName}NotFound`);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Error Flow
|
|
|
|
### Error Creation and Propagation
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Service Layer │
|
|
│ │
|
|
│ const ValidationError = require('./notifications/errors/validation');│
|
|
│ │
|
|
│ if (!user) { │
|
|
│ throw new ValidationError('auth.userNotFound'); │
|
|
│ } │
|
|
│ │ │
|
|
└─────────────────┼───────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Notifications Module │
|
|
│ │
|
|
│ 1. isNotification('auth.userNotFound') → true │
|
|
│ 2. getNotification('auth.userNotFound') │
|
|
│ → lodash.get(errors, 'auth.userNotFound') │
|
|
│ → "Sorry, we don't recognize your credentials" │
|
|
│ 3. new Error(message) with code = 400 │
|
|
│ │ │
|
|
└─────────────────┼───────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Route Handler │
|
|
│ │
|
|
│ try { │
|
|
│ await service.signin(email, password); │
|
|
│ } catch (error) { │
|
|
│ // Error propagates to error handler │
|
|
│ } │
|
|
│ │ │
|
|
└─────────────────┼───────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────┐
|
|
│ Error Handler Middleware │
|
|
│ (index.js) │
|
|
│ │
|
|
│ app.use((err, req, res, _next) => { │
|
|
│ logger.error({ err, url: req.url }, 'Unhandled error'); │
|
|
│ res.status(err.code || 500).json({ │
|
|
│ message: err.message || 'Internal server error' │
|
|
│ }); │
|
|
│ }); │
|
|
│ │
|
|
│ → Response: { "message": "Sorry, we don't recognize your credentials" }│
|
|
│ → Status: 400 │
|
|
└─────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Message Resolution Flow
|
|
|
|
```
|
|
throw new ValidationError('auth.passwordReset.invalidToken')
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────┐
|
|
│ isNotification(messageCode) │
|
|
│ lodash.get(errors, key) │
|
|
│ → "Password reset link..." │
|
|
│ → true │
|
|
└─────────────────┬───────────────────┘
|
|
│ (exists)
|
|
▼
|
|
┌─────────────────────────────────────┐
|
|
│ getNotification(messageCode) │
|
|
│ → "Password reset link is │
|
|
│ invalid or has expired" │
|
|
└─────────────────┬───────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────┐
|
|
│ new Error(message) │
|
|
│ this.message = "Password reset..."│
|
|
│ this.code = 400 │
|
|
└─────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Comparison: Notifications vs Utils Errors
|
|
|
|
The backend has two error systems that serve different purposes:
|
|
|
|
### `services/notifications/errors/` (Primary)
|
|
|
|
- **Used by**: Services, middleware, service factory
|
|
- **Integration**: Uses notification catalog for i18n
|
|
- **Message resolution**: Dynamic via `getNotification()`
|
|
- **Properties**: `message`, `code`
|
|
|
|
```javascript
|
|
// notifications/errors/validation.js
|
|
class ValidationError extends Error {
|
|
constructor(messageCode) {
|
|
const message = isNotification(messageCode)
|
|
? getNotification(messageCode)
|
|
: getNotification('errors.validation.message');
|
|
super(message);
|
|
this.code = 400;
|
|
}
|
|
}
|
|
```
|
|
|
|
### `utils/errors.js` (Utility)
|
|
|
|
- **Used by**: Lower-level utilities
|
|
- **Integration**: Direct message strings
|
|
- **Message resolution**: Static, passed at construction
|
|
- **Properties**: `message`, `statusCode`, `details`, `isOperational`
|
|
|
|
```javascript
|
|
// utils/errors.js
|
|
class AppError extends Error {
|
|
constructor(message, statusCode = 500, details = null) {
|
|
super(message);
|
|
this.statusCode = statusCode;
|
|
this.details = details;
|
|
this.isOperational = true;
|
|
}
|
|
}
|
|
|
|
class ValidationError extends AppError {
|
|
constructor(message, details = null) {
|
|
super(message, 400, details);
|
|
}
|
|
}
|
|
```
|
|
|
|
### When to Use Which
|
|
|
|
| Scenario | Use |
|
|
|----------|-----|
|
|
| Service business logic errors | `notifications/errors/ValidationError` |
|
|
| Authorization failures | `notifications/errors/ForbiddenError` |
|
|
| Email subject/body text | `getNotification()` |
|
|
| Low-level utility errors | `utils/errors.js` |
|
|
| Errors needing details object | `utils/errors.js` |
|
|
|
|
---
|
|
|
|
## Adding New Messages
|
|
|
|
### 1. Add to Catalog
|
|
|
|
```typescript
|
|
// services/notifications/list.ts
|
|
const notifications = {
|
|
// ... existing ...
|
|
|
|
projects: {
|
|
errors: {
|
|
notFound: 'Project not found',
|
|
slugTaken: 'A project with this slug already exists',
|
|
invalidSlug: 'Project slug must contain only letters, numbers, and hyphens',
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
### 2. Use in Service
|
|
|
|
```javascript
|
|
// services/projects.ts
|
|
const ValidationError = require('./notifications/errors/validation');
|
|
|
|
async function createProject(data) {
|
|
const existing = await findBySlug(data.slug);
|
|
if (existing) {
|
|
throw new ValidationError('projects.errors.slugTaken');
|
|
}
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### 3. With Parameters
|
|
|
|
```javascript
|
|
// Add to catalog
|
|
const errors = {
|
|
projects: {
|
|
errors: {
|
|
tooManyPages: 'Project cannot have more than {0} pages',
|
|
},
|
|
},
|
|
};
|
|
|
|
// Use with parameter
|
|
const MAX_PAGES = 100;
|
|
if (pageCount > MAX_PAGES) {
|
|
const message = getNotification('projects.errors.tooManyPages', MAX_PAGES);
|
|
throw new ValidationError(message);
|
|
}
|
|
// → "Project cannot have more than 100 pages"
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Unit Testing Error Classes
|
|
|
|
```javascript
|
|
describe('ValidationError', () => {
|
|
it('should resolve message from catalog', () => {
|
|
const error = new ValidationError('auth.userNotFound');
|
|
expect(error.message).toBe("Sorry, we don't recognize your credentials");
|
|
expect(error.code).toBe(400);
|
|
});
|
|
|
|
it('should use fallback for unknown keys', () => {
|
|
const error = new ValidationError('unknown.key');
|
|
expect(error.message).toBe('An error occurred');
|
|
expect(error.code).toBe(400);
|
|
});
|
|
|
|
it('should use fallback without argument', () => {
|
|
const error = new ValidationError();
|
|
expect(error.message).toBe('An error occurred');
|
|
});
|
|
});
|
|
|
|
describe('ForbiddenError', () => {
|
|
it('should resolve message from catalog', () => {
|
|
const error = new ForbiddenError('auth.forbidden');
|
|
expect(error.message).toBe('Forbidden');
|
|
expect(error.code).toBe(403);
|
|
});
|
|
});
|
|
```
|
|
|
|
### Unit Testing Helpers
|
|
|
|
```javascript
|
|
const { getNotification, isNotification } = require('./helpers');
|
|
|
|
describe('getNotification', () => {
|
|
it('should return message for valid key', () => {
|
|
expect(getNotification('auth.userNotFound')).toBe(
|
|
"Sorry, we don't recognize your credentials"
|
|
);
|
|
});
|
|
|
|
it('should substitute parameters', () => {
|
|
expect(getNotification('emails.invitation.subject', 'My App')).toBe(
|
|
"You've been invited to My App"
|
|
);
|
|
});
|
|
|
|
it('should return key for unknown path', () => {
|
|
expect(getNotification('unknown.path')).toBe('unknown.path');
|
|
});
|
|
});
|
|
|
|
describe('isNotification', () => {
|
|
it('should return true for existing keys', () => {
|
|
expect(isNotification('auth.userNotFound')).toBe(true);
|
|
});
|
|
|
|
it('should return false for unknown keys', () => {
|
|
expect(isNotification('unknown.key')).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
| Package | Version | Purpose |
|
|
|---------|---------|---------|
|
|
| `lodash/get` | ^4.x | Deep object property access |
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### 1. Use Catalog Keys, Not Raw Strings
|
|
|
|
```javascript
|
|
// Good - uses catalog for consistency
|
|
throw new ValidationError('auth.userNotFound');
|
|
|
|
// Avoid - raw strings bypass i18n
|
|
throw new ValidationError('User was not found');
|
|
```
|
|
|
|
### 2. Group Related Messages
|
|
|
|
```javascript
|
|
// Good - organized by domain
|
|
auth: {
|
|
passwordReset: {
|
|
invalidToken: '...',
|
|
error: '...',
|
|
}
|
|
}
|
|
|
|
// Avoid - flat structure
|
|
authPasswordResetInvalidToken: '...',
|
|
authPasswordResetError: '...',
|
|
```
|
|
|
|
### 3. Security-Conscious Messages
|
|
|
|
```javascript
|
|
// Good - doesn't reveal if email exists
|
|
userNotFound: "Sorry, we don't recognize your credentials",
|
|
wrongPassword: "Sorry, we don't recognize your credentials",
|
|
|
|
// Avoid - reveals email existence
|
|
userNotFound: "No account with this email exists",
|
|
wrongPassword: "Password is incorrect",
|
|
```
|
|
|
|
### 4. Parameter Substitution for Dynamic Content
|
|
|
|
```javascript
|
|
// Good - parameterized
|
|
subject: "You've been invited to {0}",
|
|
getNotification('emails.invitation.subject', appName);
|
|
|
|
// Avoid - hardcoded
|
|
subject: "You've been invited to Tour Builder",
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Services Module](./services.md) - Service layer error handling
|
|
- [Auth Module](./auth.md) - Authentication errors
|
|
- [Email Module](./email.md) - Email message templates
|
|
- [Middleware Module](./middleware.md) - Permission error handling
|