diff --git a/.github/workflows/postman.yaml b/.github/workflows/postman.yaml index 7316f6f..1134435 100644 --- a/.github/workflows/postman.yaml +++ b/.github/workflows/postman.yaml @@ -13,20 +13,44 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - run: curl -o- "https://dl-cli.pstmn.io/install/unix.sh" | sh + + - name: Set up Node.js + uses: actions/setup-node@v4 with: - node-version: "20" - cache: "yarn" + node-version: 20 + cache: "npm" - - run: curl -o- "https://dl-cli.pstmn.io/install/unix.sh" | sh + - name: Install dependencies + run: npm install - - name: Validate and sync + - name: Start server in background + run: | + npm run dev & + echo $! > pidfile + # Wait for server to be ready (port 3000) + for i in {1..20}; do + if nc -z localhost 3000; then + echo "Server is up!" + break + fi + echo "Waiting for server..." + sleep 2 + done + + - name: Validate Collection and Push to Cloud env: POSTMAN_API_KEY: ${{ secrets.POSTMAN_API_KEY }} run: | postman login --with-api-key "$POSTMAN_API_KEY" - yarn install --frozen-lockfile - yarn start & - sleep 3 - yarn test:api + postman collection run ./postman/collections/Book-API + postman workspace prepare postman workspace push -y + + - name: Stop server + if: always() + run: | + if [ -f pidfile ]; then + kill $(cat pidfile) || true + rm pidfile + fi diff --git a/.postman/resources.yaml b/.postman/resources.yaml new file mode 100644 index 0000000..c571af2 --- /dev/null +++ b/.postman/resources.yaml @@ -0,0 +1,9 @@ +# Use this workspace to collaborate +workspace: + id: 6c050d74-b1e6-4dfb-943e-2f7a2565e4e5 + +# All resources in the `postman/` folder are automatically registered in Local View. +# Point to additional files outside the `postman/` folder to register them individually. Example: +#localResources: +# collections: +# - ../tests/E2E Test Collection/ diff --git a/README.md b/README.md index 0d1b124..e62bf78 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,82 @@ -# ๐ŸŒŒ Intergalactic Bank API +# Book API -REST API for bank accounts and transactions with multi-currency support (COSMIC_COINS, GALAXY_GOLD, MOON_BUCKS). +Simple REST API for books โ€“ CRUD only, no auth. Good for demos and learning. -## Quick Start +## Quick start ```bash npm install && npm run dev -curl http://localhost:3000/health ``` -Default: **http://localhost:3000**. Generate API key: `GET /api/v1/auth`. Default admin key: `1234`. +Base URL: **http://localhost:3000** ## Endpoints -| Endpoint | Method | Auth | Description | -|----------|--------|------|-------------| -| `/health` | GET | No | Health check | -| `/api/v1/auth` | GET | No | Generate API key | -| `/api/v1/accounts` | GET, POST | Yes | List / create accounts | -| `/api/v1/accounts/:id` | GET, PUT, DELETE | Yes | Get / update / delete (soft) | -| `/api/v1/transactions` | GET, POST | Yes | List / transfer or deposit | -| `/api/v1/transactions/:id` | GET | Yes | Get transaction | +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/health` | Health check | +| GET | `/api/v1/books` | List all books | +| GET | `/api/v1/books/:id` | Get one book | +| POST | `/api/v1/books` | Create a book | +| PUT | `/api/v1/books/:id` | Update a book | +| DELETE | `/api/v1/books/:id` | Delete a book | -**Auth:** send `x-api-key: your-key` on all protected routes. **Rate limit:** 300 req/min per key. +No authentication required. -## Postman +## Examples -1. Import `OpenAPI/Bank API Reference Documentation.postman_collection.json` -2. Set `baseUrl` โ†’ `http://localhost:3000`, `apiKey` โ†’ `1234` - -## Commands - -| Command | Description | -|---------|-------------| -| `npm run dev` | Dev server (auto-reload) | -| `npm start` | Production | -| `npm test` | Run tests | -| `npm test -- --coverage` | Tests + coverage | -| `npm run lint` | Lint | - -## Config (optional `.env`) - -``` -PORT=3000 -ADMIN_API_KEY=1234 -RATE_LIMIT_REQUESTS=300 -RATE_LIMIT_WINDOW_MS=60000 -``` - -## Project Layout - -``` -src/ -โ”œโ”€โ”€ server.js # Entry -โ”œโ”€โ”€ database/db.js # In-memory store -โ”œโ”€โ”€ models/ # Account, Transaction -โ”œโ”€โ”€ routes/ # admin, accounts, transactions -โ””โ”€โ”€ middleware/ # auth, errorHandler, rateLimit +**List books** +```bash +curl http://localhost:3000/api/v1/books ``` -## Important Behavior - -- **Ownership** โ€“ Accounts are scoped to the API key that created them. -- **Soft delete** โ€“ Deleted accounts are flagged; history kept. -- **Editable** โ€“ Only `owner` and `accountType`; balance/currency change via transactions only. - -## Example Requests - -**Create account** `POST /api/v1/accounts`: -```json -{ "owner": "John Doe", "currency": "COSMIC_COINS", "balance": 1000, "accountType": "STANDARD" } +**Create book** +```bash +curl -X POST http://localhost:3000/api/v1/books \ + -H "Content-Type: application/json" \ + -d '{"title":"Dune","author":"Frank Herbert","year":1965}' ``` -**Transfer** `POST /api/v1/transactions`: -```json -{ "fromAccountId": "123", "toAccountId": "456", "amount": 500, "currency": "COSMIC_COINS" } +**Update book** (PUT with full or partial fields) +```bash +curl -X PUT http://localhost:3000/api/v1/books/1 \ + -H "Content-Type: application/json" \ + -d '{"title":"Dune","author":"Frank Herbert","year":1965}' ``` -**Deposit** โ€“ Use `"fromAccountId": "0"`. - -## Error Format - -```json -{ "error": { "name": "errorType", "message": "Description" } } +**Delete book** +```bash +curl -X DELETE http://localhost:3000/api/v1/books/1 ``` -Common codes: **400** validation, **401** auth, **403** forbidden, **404** not found, **429** rate limit, **500** server error. +## Book shape -## Sample Data (on startup) +- **title** (string, required) +- **author** (string, required) +- **year** (number, optional) -- Nova Newman (10k COSMIC_COINS), Gary Galaxy (237 COSMIC_COINS), Luna Starlight (5k GALAXY_GOLD) โ€“ all under admin key `1234`. +Responses use `{ book: {...} }` or `{ books: [...] }`. Errors use `{ error: { name, message } }`. -## Account Types & Currencies +## Commands -**Types:** STANDARD, PREMIUM, BUSINESS. **Currencies:** COSMIC_COINS, GALAXY_GOLD, MOON_BUCKS. +- `npm run dev` โ€“ dev server with reload +- `npm start` โ€“ production +- `npm test` โ€“ run tests +- `npm run lint` โ€“ lint -## Replacing Storage +## Project layout -Swap in a real DB by updating `src/database/db.js` with your driver and CRUD; the rest of the app stays the same. +``` +src/ +โ”œโ”€โ”€ server.js +โ”œโ”€โ”€ database/db.js # In-memory store +โ”œโ”€โ”€ models/Book.js +โ”œโ”€โ”€ routes/books.js +โ””โ”€โ”€ middleware/errorHandler.js +``` ---- +## Config -**More detail** โ†’ `CLAUDE.md` ยท **Tests** โ†’ `npm test` +Optional `.env`: `PORT=3000` License: ISC diff --git a/postman/collections/Book-API/.resources/definition.yaml b/postman/collections/Book-API/.resources/definition.yaml new file mode 100644 index 0000000..2094136 --- /dev/null +++ b/postman/collections/Book-API/.resources/definition.yaml @@ -0,0 +1,5 @@ +$kind: collection +name: Book API +variables: + - key: baseUrl + value: 'http://localhost:3000' diff --git a/postman/collections/Book-API/Books/create-book.request.yaml b/postman/collections/Book-API/Books/create-book.request.yaml new file mode 100644 index 0000000..091b0ca --- /dev/null +++ b/postman/collections/Book-API/Books/create-book.request.yaml @@ -0,0 +1,16 @@ +$kind: http-request +name: Create Book +method: POST +url: '{{baseUrl}}/api/v1/books' +order: 3000 +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "title": "The Great Gatsby", + "author": "F. Scott Fitzgerald", + "year": 1925 + } diff --git a/postman/collections/Book-API/Books/delete-book.request.yaml b/postman/collections/Book-API/Books/delete-book.request.yaml new file mode 100644 index 0000000..70aa60b --- /dev/null +++ b/postman/collections/Book-API/Books/delete-book.request.yaml @@ -0,0 +1,8 @@ +$kind: http-request +name: Delete Book +method: DELETE +url: '{{baseUrl}}/api/v1/books/:id' +order: 5000 +pathVariables: + - key: id + value: '1' diff --git a/postman/collections/Book-API/Books/get-book-by-id.request.yaml b/postman/collections/Book-API/Books/get-book-by-id.request.yaml new file mode 100644 index 0000000..6c69465 --- /dev/null +++ b/postman/collections/Book-API/Books/get-book-by-id.request.yaml @@ -0,0 +1,8 @@ +$kind: http-request +name: 'Get Book by ID' +method: GET +url: '{{baseUrl}}/api/v1/books/:id' +order: 2000 +pathVariables: + - key: id + value: '1' diff --git a/postman/collections/Book-API/Books/list-all-books.request.yaml b/postman/collections/Book-API/Books/list-all-books.request.yaml new file mode 100644 index 0000000..1cc813e --- /dev/null +++ b/postman/collections/Book-API/Books/list-all-books.request.yaml @@ -0,0 +1,5 @@ +$kind: http-request +name: List All Books +method: GET +url: '{{baseUrl}}/api/v1/books' +order: 1000 diff --git a/postman/collections/Book-API/Books/update-book.request.yaml b/postman/collections/Book-API/Books/update-book.request.yaml new file mode 100644 index 0000000..f7a4406 --- /dev/null +++ b/postman/collections/Book-API/Books/update-book.request.yaml @@ -0,0 +1,19 @@ +$kind: http-request +name: Update Book +method: PUT +url: '{{baseUrl}}/api/v1/books/:id' +order: 4000 +pathVariables: + - key: id + value: '1' +headers: + - key: Content-Type + value: application/json +body: + type: json + content: |- + { + "title": "The Great Gatsby (Updated)", + "author": "F. Scott Fitzgerald", + "year": 1925 + } diff --git a/postman/collections/Book-API/General/health-check.request.yaml b/postman/collections/Book-API/General/health-check.request.yaml new file mode 100644 index 0000000..908186a --- /dev/null +++ b/postman/collections/Book-API/General/health-check.request.yaml @@ -0,0 +1,5 @@ +$kind: http-request +name: Health Check +method: GET +url: '{{baseUrl}}/health' +order: 2000 diff --git a/postman/collections/Book-API/General/root.request.yaml b/postman/collections/Book-API/General/root.request.yaml new file mode 100644 index 0000000..453e9b7 --- /dev/null +++ b/postman/collections/Book-API/General/root.request.yaml @@ -0,0 +1,5 @@ +$kind: http-request +name: Root (Welcome) +method: GET +url: '{{baseUrl}}/' +order: 1000 diff --git a/postman/globals/workspace.globals.yaml b/postman/globals/workspace.globals.yaml new file mode 100644 index 0000000..e96c6d6 --- /dev/null +++ b/postman/globals/workspace.globals.yaml @@ -0,0 +1,2 @@ +name: Globals +values: [] diff --git a/src/database/db.js b/src/database/db.js index efe7676..2b72908 100644 --- a/src/database/db.js +++ b/src/database/db.js @@ -1,241 +1,57 @@ /** - * In-Memory Database - * Stores accounts and transactions in memory - * In production, this would be replaced with a real database (PostgreSQL, MongoDB, etc.) + * In-memory store for books (demo only) */ const { v4: uuidv4 } = require('uuid'); -const Account = require('../models/Account'); -const Transaction = require('../models/Transaction'); +const Book = require('../models/Book'); class Database { constructor() { - console.log('๐Ÿ”„ Initializing Database...'); - this.accounts = new Map(); - this.transactions = new Map(); - this.apiKeys = new Set(['1234']); // Default API key - this.initializeSampleData(); - console.log(`โœ… Database initialized - Accounts: ${this.accounts.size}, API Keys: ${this.apiKeys.size}`); + this.books = new Map(); + this._seed(); } - /** - * Initialize with sample data for testing - */ - initializeSampleData() { - // Create sample accounts (owned by default API key '1234') - const account1 = new Account('1', 'Nova Newman', 10000, 'COSMIC_COINS', '2023-04-10', 'STANDARD', '1234', false); - const account2 = new Account('2', 'Gary Galaxy', 237, 'COSMIC_COINS', '2023-04-10', 'PREMIUM', '1234', false); - const account3 = new Account('3', 'Luna Starlight', 5000, 'GALAXY_GOLD', '2024-01-10', 'BUSINESS', '1234', false); - - this.accounts.set('1', account1); - this.accounts.set('2', account2); - this.accounts.set('3', account3); - - // Create sample transactions - const transaction1 = new Transaction('1', '1', '2', 10000, 'COSMIC_COINS', '2024-01-10'); - this.transactions.set('1', transaction1); + _seed() { + const sample = [ + new Book('1', 'The Great Gatsby', 'F. Scott Fitzgerald', 1925), + new Book('2', '1984', 'George Orwell', 1949) + ]; + sample.forEach(b => this.books.set(b.id, b)); } - // ============ Account Operations ============ - - /** - * Get all accounts with optional filters - * @param {Object} filters - Optional filters (owner, createdAt, apiKey) - * @returns {Array} - */ - getAccounts(filters = {}) { - let accounts = Array.from(this.accounts.values()); - - // Exclude deleted accounts - accounts = accounts.filter(acc => !acc.deleted); - - // Filter by API key for ownership - if (filters.apiKey) { - accounts = accounts.filter(acc => acc.apiKey === filters.apiKey); - } - - if (filters.owner) { - accounts = accounts.filter(acc => acc.owner.toLowerCase().includes(filters.owner.toLowerCase())); - } - - if (filters.createdAt) { - accounts = accounts.filter(acc => acc.createdAt === filters.createdAt); - } - - return accounts; + getBooks() { + return Array.from(this.books.values()); } - /** - * Get account by ID - * @param {string} accountId - * @returns {Account|null} - */ - getAccountById(accountId) { - return this.accounts.get(accountId) || null; + getBookById(id) { + return this.books.get(id) || null; } - /** - * Create new account - * @param {Object} accountData - * @param {string} apiKey - API key of the creator - * @returns {Account} - */ - createAccount(accountData, apiKey) { - const accountId = uuidv4().split('-')[0]; // Generate short UUID - const account = new Account( - accountId, - accountData.owner, - accountData.balance || 0, - accountData.currency, - new Date().toISOString().split('T')[0], - accountData.accountType || 'STANDARD', - apiKey, - false - ); - this.accounts.set(accountId, account); - console.log(`โœ“ Account created: ${accountId} - Total accounts: ${this.accounts.size}`); - return account; + createBook(data) { + const id = uuidv4().slice(0, 8); + const book = new Book(id, data.title.trim(), data.author.trim(), data.year ?? null); + this.books.set(id, book); + return book; } - /** - * Update account - * @param {string} accountId - * @param {Object} updates - * @returns {Account|null} - */ - updateAccount(accountId, updates) { - const account = this.accounts.get(accountId); - if (!account || account.deleted) { - return null; - } - - if (updates.owner) { - account.owner = updates.owner; - } - - if (updates.currency) { - account.currency = updates.currency; - } - - if (updates.accountType) { - account.accountType = updates.accountType; - } - - return account; + updateBook(id, data) { + const book = this.books.get(id); + if (!book) return null; + if (data.title !== undefined) book.title = data.title.trim(); + if (data.author !== undefined) book.author = data.author.trim(); + if (data.year !== undefined) book.year = data.year; + return book; } - /** - * Delete account (soft delete) - * @param {string} accountId - * @returns {boolean} - */ - deleteAccount(accountId) { - const account = this.accounts.get(accountId); - if (!account || account.deleted) { - return false; - } - account.deleted = true; - console.log(`โœ“ Account soft deleted: ${accountId}`); - return true; + deleteBook(id) { + return this.books.delete(id); } - // ============ Transaction Operations ============ - - /** - * Get all transactions with optional filters - * @param {Object} filters - Optional filters (fromAccountId, toAccountId, createdAt) - * @returns {Array} - */ - getTransactions(filters = {}) { - let transactions = Array.from(this.transactions.values()); - - if (filters.fromAccountId) { - transactions = transactions.filter(tx => tx.fromAccountId === filters.fromAccountId); - } - - if (filters.toAccountId) { - transactions = transactions.filter(tx => tx.toAccountId === filters.toAccountId); - } - - if (filters.createdAt) { - transactions = transactions.filter(tx => tx.createdAt === filters.createdAt); - } - - return transactions; - } - - /** - * Get transaction by ID - * @param {string} transactionId - * @returns {Transaction|null} - */ - getTransactionById(transactionId) { - return this.transactions.get(transactionId) || null; - } - - /** - * Check if account has any transactions - * @param {string} accountId - * @returns {boolean} - */ - accountHasTransactions(accountId) { - const transactions = Array.from(this.transactions.values()); - return transactions.some(tx => - tx.fromAccountId === accountId || tx.toAccountId === accountId - ); - } - - /** - * Create new transaction - * @param {Object} transactionData - * @returns {Transaction} - */ - createTransaction(transactionData) { - const transactionId = uuidv4().split('-')[0]; // Generate short UUID - const transaction = new Transaction( - transactionId, - transactionData.fromAccountId, - transactionData.toAccountId, - transactionData.amount, - transactionData.currency, - new Date().toISOString().split('T')[0] - ); - this.transactions.set(transactionId, transaction); - return transaction; - } - - // ============ API Key Operations ============ - - /** - * Generate new API key - * @returns {string} - */ - generateApiKey() { - const apiKey = uuidv4().replace(/-/g, '').substring(0, 16); - this.apiKeys.add(apiKey); - console.log(`โœ“ API Key generated: ${apiKey} - Total keys: ${this.apiKeys.size}`); - return apiKey; - } - - /** - * Add an API key to the database - * @param {string} apiKey - */ - addApiKey(apiKey) { - this.apiKeys.add(apiKey); - console.log(`โœ“ API Key registered: ${apiKey} - Total keys: ${this.apiKeys.size}`); - } - - /** - * Validate API key - * @param {string} apiKey - * @returns {boolean} - */ - validateApiKey(apiKey) { - return this.apiKeys.has(apiKey); + /** Reset store and re-seed (for tests) */ + reset() { + this.books.clear(); + this._seed(); } } -// Export singleton instance module.exports = new Database(); - diff --git a/src/middleware/auth.js b/src/middleware/auth.js deleted file mode 100644 index cacbf98..0000000 --- a/src/middleware/auth.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Authentication Middleware - * Validates API keys and checks permissions - */ - -const db = require('../database/db'); - -/** - * Middleware to validate API key - */ -const validateApiKey = (req, res, next) => { - - const apiKey = req.headers['x-api-key'] || req.headers['api-key']; - - if (!apiKey) { - return res.status(401).json({ - error: { - name: 'authenticationError', - message: 'API key is required. Please provide an API key in the x-api-key header.' - } - }); - } - - // If API key doesn't exist, automatically register it - if (!db.validateApiKey(apiKey)) { - db.addApiKey(apiKey); - } - - // Store API key in request for potential admin check - req.apiKey = apiKey; - next(); -}; - -/** - * Middleware to check if user has admin permissions - * For this demo, we'll use the default API key '1234' as admin - */ -const requireAdmin = (req, res, next) => { - const adminKey = process.env.ADMIN_API_KEY || '1234'; - - if (req.apiKey !== adminKey) { - return res.status(403).json({ - error: { - name: 'forbiddenError', - message: 'You do not have permissions to perform this action. Admin access required.' - } - }); - } - - next(); -}; - -module.exports = { - validateApiKey, - requireAdmin -}; - diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js deleted file mode 100644 index 8c8f8e8..0000000 --- a/src/middleware/rateLimit.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Rate Limiting Middleware - * Implements simple in-memory rate limiting - */ - -class RateLimiter { - constructor(maxRequests = 300, windowMs = 60000) { - this.maxRequests = maxRequests; - this.windowMs = windowMs; - this.requests = new Map(); // Map of IP/Key -> array of timestamps - } - - /** - * Middleware function to apply rate limiting - */ - middleware() { - return (req, res, next) => { - const identifier = req.apiKey || req.ip; - const now = Date.now(); - - // Get existing requests for this identifier - if (!this.requests.has(identifier)) { - this.requests.set(identifier, []); - } - - const userRequests = this.requests.get(identifier); - - // Remove old requests outside the time window - const validRequests = userRequests.filter(timestamp => now - timestamp < this.windowMs); - - // Check if rate limit exceeded - if (validRequests.length >= this.maxRequests) { - const oldestRequest = validRequests[0]; - const resetTime = Math.ceil((oldestRequest + this.windowMs) / 1000); - - res.setHeader('X-RateLimit-Limit', this.maxRequests); - res.setHeader('X-RateLimit-Remaining', 0); - res.setHeader('X-RateLimit-Reset', resetTime); - - return res.status(429).json({ - error: { - name: 'rateLimitExceeded', - message: `Too many requests. Maximum ${this.maxRequests} requests per minute allowed.` - } - }); - } - - // Add current request - validRequests.push(now); - this.requests.set(identifier, validRequests); - - // Set rate limit headers - res.setHeader('X-RateLimit-Limit', this.maxRequests); - res.setHeader('X-RateLimit-Remaining', this.maxRequests - validRequests.length); - res.setHeader('X-RateLimit-Reset', Math.ceil((now + this.windowMs) / 1000)); - - next(); - }; - } - - /** - * Clean up old entries periodically - */ - cleanup() { - const now = Date.now(); - for (const [identifier, timestamps] of this.requests.entries()) { - const validRequests = timestamps.filter(timestamp => now - timestamp < this.windowMs); - if (validRequests.length === 0) { - this.requests.delete(identifier); - } else { - this.requests.set(identifier, validRequests); - } - } - } -} - -// Create rate limiter instance -const rateLimiterRequests = parseInt(process.env.RATE_LIMIT_REQUESTS) || 300; -const rateLimiterWindow = parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000; -const rateLimiter = new RateLimiter(rateLimiterRequests, rateLimiterWindow); - -// Cleanup every 5 minutes -setInterval(() => rateLimiter.cleanup(), 5 * 60 * 1000); - -module.exports = rateLimiter.middleware(); - diff --git a/src/models/Account.js b/src/models/Account.js deleted file mode 100644 index 764cff7..0000000 --- a/src/models/Account.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Account Model - * Represents a bank account in the system - */ - -class Account { - constructor(accountId, owner, balance = 0, currency, createdAt = new Date().toISOString().split('T')[0], accountType = 'STANDARD', apiKey = null, deleted = false) { - this.accountId = accountId; - this.owner = owner; - this.balance = balance; - this.currency = currency; - this.createdAt = createdAt; - this.accountType = accountType; - this.apiKey = apiKey; - this.deleted = deleted; - } - - /** - * Validates account data - * @param {Object} data - Account data to validate - * @returns {Object} - Validation result with isValid and error properties - */ - static validate(data) { - const validCurrencies = ['COSMIC_COINS', 'GALAXY_GOLD', 'MOON_BUCKS']; - const validAccountTypes = ['STANDARD', 'PREMIUM', 'BUSINESS']; - - if (!data.owner || typeof data.owner !== 'string') { - return { isValid: false, error: 'Owner name is required and must be a string' }; - } - - if (!data.currency || !validCurrencies.includes(data.currency)) { - return { isValid: false, error: `Currency must be one of: ${validCurrencies.join(', ')}` }; - } - - if (data.balance !== undefined && (typeof data.balance !== 'number' || data.balance < 0)) { - return { isValid: false, error: 'Balance must be a non-negative number' }; - } - - if (data.accountType && !validAccountTypes.includes(data.accountType)) { - return { isValid: false, error: `Account type must be one of: ${validAccountTypes.join(', ')}` }; - } - - return { isValid: true }; - } - - /** - * Updates account balance - * @param {number} amount - Amount to add (positive) or subtract (negative) - */ - updateBalance(amount) { - this.balance += amount; - } - - /** - * Checks if account has sufficient funds - * @param {number} amount - Amount to check - * @returns {boolean} - */ - hasSufficientFunds(amount) { - return this.balance >= amount; - } - - /** - * Converts account to JSON representation - * @returns {Object} - */ - toJSON() { - return { - accountId: this.accountId, - owner: this.owner, - accountType: this.accountType, - createdAt: this.createdAt, - balance: this.balance, - currency: this.currency - }; - } -} - -module.exports = Account; - diff --git a/src/models/Book.js b/src/models/Book.js new file mode 100644 index 0000000..8711403 --- /dev/null +++ b/src/models/Book.js @@ -0,0 +1,36 @@ +/** + * Book model โ€“ title, author, optional year + */ + +class Book { + constructor(id, title, author, year = null) { + this.id = id; + this.title = title; + this.author = author; + this.year = year; + } + + static validate(data) { + if (!data.title || typeof data.title !== 'string' || !data.title.trim()) { + return { isValid: false, error: 'Title is required' }; + } + if (!data.author || typeof data.author !== 'string' || !data.author.trim()) { + return { isValid: false, error: 'Author is required' }; + } + if (data.year != null && (typeof data.year !== 'number' || data.year < 0 || !Number.isInteger(data.year))) { + return { isValid: false, error: 'Year must be a non-negative integer' }; + } + return { isValid: true }; + } + + toJSON() { + return { + id: this.id, + title: this.title, + author: this.author, + year: this.year + }; + } +} + +module.exports = Book; diff --git a/src/models/Transaction.js b/src/models/Transaction.js deleted file mode 100644 index 1fb9086..0000000 --- a/src/models/Transaction.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Transaction Model - * Represents a transaction between accounts - */ - -class Transaction { - constructor(transactionId, fromAccountId, toAccountId, amount, currency, createdAt = new Date().toISOString().split('T')[0]) { - this.transactionId = transactionId; - this.fromAccountId = fromAccountId; - this.toAccountId = toAccountId; - this.amount = amount; - this.currency = currency; - this.createdAt = createdAt; - } - - /** - * Validates transaction data - * @param {Object} data - Transaction data to validate - * @returns {Object} - Validation result with isValid and error properties - */ - static validate(data) { - if (!data.fromAccountId && data.fromAccountId !== '0') { - return { isValid: false, error: 'fromAccountId is required' }; - } - - if (!data.toAccountId) { - return { isValid: false, error: 'toAccountId is required' }; - } - - if (!data.amount || typeof data.amount !== 'number' || data.amount <= 0) { - return { isValid: false, error: 'Amount must be a positive number' }; - } - - if (!data.currency) { - return { isValid: false, error: 'Currency is required' }; - } - - return { isValid: true }; - } - - /** - * Checks if transaction is a deposit (from external source) - * @returns {boolean} - */ - isDeposit() { - return this.fromAccountId === '0'; - } - - /** - * Converts transaction to JSON representation - * @returns {Object} - */ - toJSON() { - return { - transactionId: this.transactionId, - createdAt: this.createdAt, - amount: this.amount, - currency: this.currency, - fromAccountId: this.fromAccountId, - toAccountId: this.toAccountId - }; - } -} - -module.exports = Transaction; - diff --git a/src/routes/accounts.js b/src/routes/accounts.js deleted file mode 100644 index 10311fc..0000000 --- a/src/routes/accounts.js +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Account Routes - * Handles all account-related operations - */ - -const express = require('express'); -const router = express.Router(); -const db = require('../database/db'); -const Account = require('../models/Account'); -const { validateApiKey } = require('../middleware/auth'); - -/** - * GET /api/v1/accounts - * List all accounts with optional filters - */ -router.get('/', validateApiKey, (req, res) => { - try { - const { owner, createdAt } = req.query; - - // Filter by API key for ownership and other optional filters - const filters = { apiKey: req.apiKey }; - if (owner) filters.owner = owner; - if (createdAt) filters.createdAt = createdAt; - - const accounts = db.getAccounts(filters); - - res.status(200).json({ - accounts: accounts.map(acc => acc.toJSON()) - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to retrieve accounts' - } - }); - } -}); - -/** - * POST /api/v1/accounts - * Create a new account - */ -router.post('/', validateApiKey, (req, res) => { - try { - const accountData = req.body; - - // Validate account data - const validation = Account.validate(accountData); - if (!validation.isValid) { - return res.status(400).json({ - error: { - name: 'validationError', - message: validation.error - } - }); - } - - // Create account with API key for ownership - const account = db.createAccount(accountData, req.apiKey); - - res.status(201).json({ - account: { - accountId: account.accountId - } - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to create account' - } - }); - } -}); - -/** - * GET /api/v1/accounts/:id - * Get a single account by ID - */ -router.get('/:id', validateApiKey, (req, res) => { - try { - const { id } = req.params; - - // Get account from database - const account = db.getAccountById(id); - - // Check if account exists - if (!account || account.deleted) { - return res.status(404).json({ - error: { - name: 'notFound', - message: 'Account not found' - } - }); - } - - // Check ownership - users can only access their own accounts - if (account.apiKey !== req.apiKey) { - return res.status(403).json({ - error: { - name: 'forbidden', - message: 'Access denied. You can only access your own accounts.' - } - }); - } - - res.status(200).json({ - account: account.toJSON() - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to retrieve account' - } - }); - } -}); - -/** - * PUT /api/v1/accounts/:id - * Update an existing account - */ -router.put('/:id', validateApiKey, (req, res) => { - try { - const { id } = req.params; - const updates = req.body; - - // Get account from database - const account = db.getAccountById(id); - - // Check if account exists - if (!account || account.deleted) { - return res.status(404).json({ - error: { - name: 'notFound', - message: 'Account not found' - } - }); - } - - // Check ownership - users can only update their own accounts - if (account.apiKey !== req.apiKey) { - return res.status(403).json({ - error: { - name: 'forbidden', - message: 'Access denied. You can only update your own accounts.' - } - }); - } - - // Prevent balance updates (balance only changes via transactions) - if (updates.balance !== undefined) { - return res.status(400).json({ - error: { - name: 'validationError', - message: 'Balance cannot be updated directly. Use transaction endpoints to modify balance.' - } - }); - } - - // Prevent updates to immutable fields - if (updates.accountId || updates.createdAt || updates.apiKey || updates.deleted !== undefined) { - return res.status(400).json({ - error: { - name: 'validationError', - message: 'Cannot update accountId, createdAt, apiKey, or deleted fields.' - } - }); - } - - // Validate updatable fields - const validationData = { ...account, ...updates }; - const validation = Account.validate(validationData); - if (!validation.isValid) { - return res.status(400).json({ - error: { - name: 'validationError', - message: validation.error - } - }); - } - - // Update account - const updatedAccount = db.updateAccount(id, updates); - - res.status(200).json({ - account: updatedAccount.toJSON() - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to update account' - } - }); - } -}); - -/** - * DELETE /api/v1/accounts/:id - * Delete an account (soft delete) - */ -router.delete('/:id', validateApiKey, (req, res) => { - try { - const { id } = req.params; - - // Get account from database - const account = db.getAccountById(id); - - // Check if account exists - if (!account || account.deleted) { - return res.status(404).json({ - error: { - name: 'notFound', - message: 'Account not found' - } - }); - } - - // Check ownership - users can only delete their own accounts - if (account.apiKey !== req.apiKey) { - return res.status(403).json({ - error: { - name: 'forbidden', - message: 'Access denied. You can only delete your own accounts.' - } - }); - } - - // Check if account has transactions - const hasTransactions = db.accountHasTransactions(id); - - // Always perform soft delete - const deleted = db.deleteAccount(id); - - if (!deleted) { - return res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to delete account' - } - }); - } - - res.status(200).json({ - message: 'Account deleted successfully', - accountId: id, - deletionType: hasTransactions ? 'soft (has transactions)' : 'soft' - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to delete account' - } - }); - } -}); - -module.exports = router; - diff --git a/src/routes/admin.js b/src/routes/admin.js deleted file mode 100644 index 86169cb..0000000 --- a/src/routes/admin.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Admin Routes - * Handles administrative operations like API key generation - */ - -const express = require('express'); -const router = express.Router(); -const db = require('../database/db'); - -/** - * GET /api/v1/auth - * Generate a new API key - */ -router.get('/auth', (req, res) => { - try { - const apiKey = db.generateApiKey(); - - res.status(200).json({ - apiKey: apiKey - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to generate API key' - } - }); - } -}); - -module.exports = router; - diff --git a/src/routes/books.js b/src/routes/books.js new file mode 100644 index 0000000..102744e --- /dev/null +++ b/src/routes/books.js @@ -0,0 +1,60 @@ +/** + * Book CRUD routes โ€“ no auth + */ + +const express = require('express'); +const router = express.Router(); +const db = require('../database/db'); +const Book = require('../models/Book'); + +// List books +router.get('/', (req, res) => { + const books = db.getBooks(); + res.json({ books: books.map(b => b.toJSON()) }); +}); + +// Get one book +router.get('/:id', (req, res) => { + const book = db.getBookById(req.params.id); + if (!book) { + return res.status(404).json({ error: { name: 'notFound', message: 'Book not found' } }); + } + res.json({ book: book.toJSON() }); +}); + +// Create book +router.post('/', (req, res) => { + const validation = Book.validate(req.body); + if (!validation.isValid) { + return res.status(400).json({ error: { name: 'validationError', message: validation.error } }); + } + const book = db.createBook(req.body); + res.status(201).json({ book: book.toJSON() }); +}); + +// Update book +router.put('/:id', (req, res) => { + const book = db.getBookById(req.params.id); + if (!book) { + return res.status(404).json({ error: { name: 'notFound', message: 'Book not found' } }); + } + const updates = req.body; + const merged = { title: updates.title ?? book.title, author: updates.author ?? book.author, year: updates.year !== undefined ? updates.year : book.year }; + const validation = Book.validate(merged); + if (!validation.isValid) { + return res.status(400).json({ error: { name: 'validationError', message: validation.error } }); + } + const updated = db.updateBook(req.params.id, updates); + res.json({ book: updated.toJSON() }); +}); + +// Delete book +router.delete('/:id', (req, res) => { + const ok = db.deleteBook(req.params.id); + if (!ok) { + return res.status(404).json({ error: { name: 'notFound', message: 'Book not found' } }); + } + res.status(200).json({ message: 'Book deleted', id: req.params.id }); +}); + +module.exports = router; diff --git a/src/routes/transactions.js b/src/routes/transactions.js deleted file mode 100644 index a6f3e54..0000000 --- a/src/routes/transactions.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Transaction Routes - * Handles all transaction-related operations - */ - -const express = require('express'); -const router = express.Router(); -const db = require('../database/db'); -const Transaction = require('../models/Transaction'); -const { validateApiKey } = require('../middleware/auth'); - -/** - * GET /api/v1/transactions - * List all transactions with optional filters - */ -router.get('/', validateApiKey, (req, res) => { - try { - const { fromAccountId, toAccountId, createdAt } = req.query; - - const filters = {}; - if (fromAccountId) filters.fromAccountId = fromAccountId; - if (toAccountId) filters.toAccountId = toAccountId; - if (createdAt) filters.createdAt = createdAt; - - const transactions = db.getTransactions(filters); - - res.status(200).json({ - transactions: transactions.map(tx => tx.toJSON()) - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to retrieve transactions' - } - }); - } -}); - -/** - * GET /api/v1/transactions/:transactionId - * Get a specific transaction by ID - */ -router.get('/:transactionId', validateApiKey, (req, res) => { - try { - const { transactionId } = req.params; - - const transaction = db.getTransactionById(transactionId); - - if (!transaction) { - return res.status(404).json({ - error: { - name: 'instanceNotFoundError', - message: 'The specified transaction does not exist.' - } - }); - } - - res.status(200).json({ - transaction: transaction.toJSON() - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to retrieve transaction' - } - }); - } -}); - -/** - * POST /api/v1/transactions - * Create a new transaction (transfer or deposit) - */ -router.post('/', validateApiKey, (req, res) => { - try { - const transactionData = req.body; - - // Validate transaction data - const validation = Transaction.validate(transactionData); - if (!validation.isValid) { - return res.status(400).json({ - error: { - name: 'validationError', - message: validation.error - } - }); - } - - // Check if destination account exists - const toAccount = db.getAccountById(transactionData.toAccountId); - if (!toAccount) { - return res.status(404).json({ - error: { - name: 'instanceNotFoundError', - message: 'Destination account does not exist.' - } - }); - } - - // Check if currencies match - if (toAccount.currency !== transactionData.currency) { - return res.status(400).json({ - error: { - name: 'validationError', - message: `Currency mismatch. Account uses ${toAccount.currency} but transaction uses ${transactionData.currency}` - } - }); - } - - // If not a deposit, validate source account and check funds - if (transactionData.fromAccountId !== '0') { - const fromAccount = db.getAccountById(transactionData.fromAccountId); - - if (!fromAccount) { - return res.status(404).json({ - error: { - name: 'instanceNotFoundError', - message: 'Source account does not exist.' - } - }); - } - - // Check if source account has sufficient funds - if (!fromAccount.hasSufficientFunds(transactionData.amount)) { - return res.status(403).json({ - error: { - name: 'txInsufficientFunds', - message: 'Not enough funds in source account to complete transaction.' - } - }); - } - - // Check if currencies match - if (fromAccount.currency !== transactionData.currency) { - return res.status(400).json({ - error: { - name: 'validationError', - message: 'Currency mismatch between accounts' - } - }); - } - - // Deduct from source account - fromAccount.updateBalance(-transactionData.amount); - } - - // Add to destination account - toAccount.updateBalance(transactionData.amount); - - // Create transaction record - const transaction = db.createTransaction(transactionData); - - res.status(201).json({ - transaction: { - transactionId: transaction.transactionId - } - }); - } catch (error) { - res.status(500).json({ - error: { - name: 'serverError', - message: 'Failed to create transaction' - } - }); - } -}); - -module.exports = router; - diff --git a/src/server.js b/src/server.js index 96e08b5..8b316d3 100644 --- a/src/server.js +++ b/src/server.js @@ -1,116 +1,33 @@ -/** - * Intergalactic Bank API Server - * Main server file that initializes and starts the Express application - */ - require('dotenv').config(); const express = require('express'); const cors = require('cors'); - -// Import middleware -const rateLimit = require('./middleware/rateLimit'); const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); +const bookRoutes = require('./routes/books'); -// Import routes -const adminRoutes = require('./routes/admin'); -const accountRoutes = require('./routes/accounts'); -const transactionRoutes = require('./routes/transactions'); - -// Initialize Express app const app = express(); const PORT = process.env.PORT || 3000; -// ============ Global Middleware ============ - -// CORS - Allow all origins (configure as needed for production) app.use(cors()); - -// Body parser - Parse JSON request bodies app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -// Rate limiting - Apply to all routes -app.use(rateLimit); - -// Request logging (simple) -app.use((req, res, next) => { - console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); - next(); -}); - -// ============ API Routes ============ -// Health check endpoint app.get('/health', (req, res) => { - res.status(200).json({ - status: 'healthy', - timestamp: new Date().toISOString(), - uptime: process.uptime() - }); + res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// Welcome endpoint app.get('/', (req, res) => { - res.status(200).json({ - message: 'Welcome to the Intergalactic Bank API! ๐ŸŒŒ', - version: '1.0.0', - documentation: 'See README.md for API documentation', - endpoints: { - health: 'GET /health', - auth: 'GET /api/v1/auth', - accounts: 'GET /api/v1/accounts', - transactions: 'GET /api/v1/transactions' - } - }); + res.json({ message: 'Book API', docs: 'GET /api/v1/books' }); }); -// Mount API routes -app.use('/api/v1', adminRoutes); -app.use('/api/v1/accounts', accountRoutes); -app.use('/api/v1/transactions', transactionRoutes); - -// ============ Error Handling ============ +app.use('/api/v1/books', bookRoutes); -// 404 handler for undefined routes app.use(notFoundHandler); - -// Global error handler app.use(errorHandler); -// ============ Start Server ============ - -const server = app.listen(PORT, () => { - console.log('โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—'); - console.log('โ•‘ ๐ŸŒŒ Intergalactic Bank API Server ๐ŸŒŒ โ•‘'); - console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); - console.log(''); - console.log(`๐Ÿš€ Server running on port ${PORT}`); - console.log(`๐Ÿ“ก Environment: ${process.env.NODE_ENV || 'development'}`); - console.log(`๐Ÿ”— URL: http://localhost:${PORT}`); - console.log(`๐Ÿ’š Health Check: http://localhost:${PORT}/health`); - console.log(''); - console.log('Available endpoints:'); - console.log(' - GET /api/v1/auth'); - console.log(' - GET /api/v1/accounts'); - console.log(' - POST /api/v1/accounts'); - console.log(' - GET /api/v1/accounts/:accountId'); - console.log(' - PATCH /api/v1/accounts/:accountId'); - console.log(' - DELETE /api/v1/accounts/:accountId'); - console.log(' - GET /api/v1/transactions'); - console.log(' - POST /api/v1/transactions'); - console.log(' - GET /api/v1/transactions/:transactionId'); - console.log(''); - console.log('Press CTRL+C to stop the server'); - console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); -}); - -// Graceful shutdown -process.on('SIGTERM', () => { - console.log('SIGTERM signal received: closing HTTP server'); - server.close(() => { - console.log('HTTP server closed'); +if (require.main === module) { + const server = app.listen(PORT, () => { + console.log(`Book API running at http://localhost:${PORT}`); }); -}); + process.on('SIGTERM', () => server.close(() => console.log('Server closed'))); +} module.exports = app; - diff --git a/tests/integration/accounts.test.js b/tests/integration/accounts.test.js deleted file mode 100644 index 1f89d80..0000000 --- a/tests/integration/accounts.test.js +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Account Routes Integration Tests - */ - -const request = require('supertest'); -const express = require('express'); -const accountRoutes = require('../../src/routes/accounts'); - -// Mock auth middleware -jest.mock('../../src/middleware/auth', () => ({ - validateApiKey: (req, res, next) => { - req.apiKey = req.headers['x-api-key'] || 'test-key'; - next(); - }, - requireAdmin: (req, res, next) => { - if (req.apiKey === 'admin-key') { - next(); - } else { - res.status(403).json({ - error: { - name: 'forbiddenError', - message: 'You do not have permissions to perform this action. Admin access required.' - } - }); - } - } -})); - -const db = require('../../src/database/db'); - -// Create fresh database for each test -beforeEach(() => { - // Clear and reinitialize - db.accounts.clear(); - db.transactions.clear(); - db.apiKeys.clear(); - db.apiKeys.add('1234'); // Re-add default API key - db.initializeSampleData(); -}); - -// Create test app -const app = express(); -app.use(express.json()); -app.use('/api/v1/accounts', accountRoutes); - -describe('Account Routes', () => { - describe('GET /api/v1/accounts', () => { - test('should return all accounts', async () => { - const response = await request(app) - .get('/api/v1/accounts') - .set('x-api-key', 'test-key') - .expect(200); - - expect(response.body).toHaveProperty('accounts'); - expect(Array.isArray(response.body.accounts)).toBe(true); - expect(response.body.accounts.length).toBeGreaterThan(0); - }); - - test('should filter accounts by owner', async () => { - const response = await request(app) - .get('/api/v1/accounts?owner=Nova') - .set('x-api-key', 'test-key') - .expect(200); - - expect(response.body.accounts.length).toBeGreaterThan(0); - expect(response.body.accounts[0].owner).toContain('Nova'); - }); - - test('should filter accounts by createdAt', async () => { - const response = await request(app) - .get('/api/v1/accounts?createdAt=2023-04-10') - .set('x-api-key', 'test-key') - .expect(200); - - expect(Array.isArray(response.body.accounts)).toBe(true); - response.body.accounts.forEach(account => { - expect(account.createdAt).toBe('2023-04-10'); - }); - }); - - test('should return empty array for non-matching filters', async () => { - const response = await request(app) - .get('/api/v1/accounts?owner=NonExistent') - .set('x-api-key', 'test-key') - .expect(200); - - expect(response.body.accounts).toEqual([]); - }); - }); - - describe('GET /api/v1/accounts/:accountId', () => { - }); - - describe('POST /api/v1/accounts', () => { - test('should create a new account', async () => { - const newAccount = { - owner: 'Test User', - balance: 5000, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/accounts') - .set('x-api-key', 'test-key') - .send(newAccount) - .expect(201); - - expect(response.body).toHaveProperty('account'); - expect(response.body.account).toHaveProperty('accountId'); - }); - - test('should reject account with missing owner', async () => { - const invalidAccount = { - balance: 5000, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/accounts') - .set('x-api-key', 'test-key') - .send(invalidAccount) - .expect(400); - - expect(response.body.error.name).toBe('validationError'); - }); - - test('should reject account with invalid currency', async () => { - const invalidAccount = { - owner: 'Test User', - balance: 5000, - currency: 'INVALID_CURRENCY' - }; - - const response = await request(app) - .post('/api/v1/accounts') - .set('x-api-key', 'test-key') - .send(invalidAccount) - .expect(400); - - expect(response.body.error.name).toBe('validationError'); - }); - - test('should reject account with negative balance', async () => { - const invalidAccount = { - owner: 'Test User', - balance: -100, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/accounts') - .set('x-api-key', 'test-key') - .send(invalidAccount) - .expect(400); - - expect(response.body.error.name).toBe('validationError'); - }); - }); - - describe('PATCH /api/v1/accounts/:accountId', () => { - test('should update account with admin key', async () => { - const updates = { - owner: 'Updated Name' - }; - - const response = await request(app) - .patch('/api/v1/accounts/1') - .set('x-api-key', 'admin-key') - .send(updates) - .expect(200); - - expect(response.body.account.owner).toBe('Updated Name'); - expect(response.body.account.accountId).toBe('1'); - }); - - test('should reject update without admin key', async () => { - const updates = { - owner: 'Updated Name' - }; - - await request(app) - .patch('/api/v1/accounts/1') - .set('x-api-key', 'regular-key') - .send(updates) - .expect(403); - }); - - test('should return 404 for non-existent account', async () => { - const updates = { - owner: 'Updated Name' - }; - - await request(app) - .patch('/api/v1/accounts/999') - .set('x-api-key', 'admin-key') - .send(updates) - .expect(404); - }); - }); - - describe('DELETE /api/v1/accounts/:accountId', () => { - test('should delete account with admin key', async () => { - await request(app) - .delete('/api/v1/accounts/1') - .set('x-api-key', 'admin-key') - .expect(204); - - // Verify account is deleted - await request(app) - .get('/api/v1/accounts/1') - .set('x-api-key', 'test-key') - .expect(404); - }); - - test('should reject delete without admin key', async () => { - await request(app) - .delete('/api/v1/accounts/1') - .set('x-api-key', 'regular-key') - .expect(403); - }); - - test('should return 404 for non-existent account', async () => { - await request(app) - .delete('/api/v1/accounts/999') - .set('x-api-key', 'admin-key') - .expect(404); - }); - }); -}); - diff --git a/tests/integration/admin.test.js b/tests/integration/admin.test.js deleted file mode 100644 index 72d814c..0000000 --- a/tests/integration/admin.test.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Admin Routes Integration Tests - */ - -const request = require('supertest'); -const express = require('express'); -const adminRoutes = require('../../src/routes/admin'); - -// Create test app -const app = express(); -app.use(express.json()); -app.use('/api/v1', adminRoutes); - -describe('Admin Routes', () => { - describe('GET /api/v1/auth', () => { - test('should generate a new API key', async () => { - const response = await request(app) - .get('/api/v1/auth') - .expect(200); - - expect(response.body).toHaveProperty('apiKey'); - expect(typeof response.body.apiKey).toBe('string'); - expect(response.body.apiKey.length).toBeGreaterThan(0); - }); - - test('should return valid JSON', async () => { - const response = await request(app) - .get('/api/v1/auth') - .expect('Content-Type', /json/) - .expect(200); - - expect(response.body).toBeInstanceOf(Object); - }); - - test('should generate different API keys on subsequent requests', async () => { - const response1 = await request(app).get('/api/v1/auth'); - const response2 = await request(app).get('/api/v1/auth'); - - expect(response1.body.apiKey).not.toBe(response2.body.apiKey); - }); - }); -}); - diff --git a/tests/integration/books.test.js b/tests/integration/books.test.js new file mode 100644 index 0000000..8eab02e --- /dev/null +++ b/tests/integration/books.test.js @@ -0,0 +1,58 @@ +const request = require('supertest'); +const app = require('../../src/server'); +const db = require('../../src/database/db'); + +beforeEach(() => { + db.reset(); +}); + +describe('Book API', () => { + test('GET /api/v1/books returns list', async () => { + const res = await request(app).get('/api/v1/books').expect(200); + expect(res.body.books).toBeInstanceOf(Array); + expect(res.body.books.length).toBeGreaterThanOrEqual(2); + }); + + test('GET /api/v1/books/:id returns one book', async () => { + const res = await request(app).get('/api/v1/books/1').expect(200); + expect(res.body.book).toMatchObject({ id: '1', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 }); + }); + + test('GET /api/v1/books/:id returns 404 for missing id', async () => { + await request(app).get('/api/v1/books/nonexistent').expect(404); + }); + + test('POST /api/v1/books creates a book', async () => { + const res = await request(app) + .post('/api/v1/books') + .send({ title: 'Dune', author: 'Frank Herbert', year: 1965 }) + .expect(201); + expect(res.body.book).toMatchObject({ title: 'Dune', author: 'Frank Herbert', year: 1965 }); + expect(res.body.book.id).toBeDefined(); + }); + + test('POST /api/v1/books returns 400 without title', async () => { + await request(app) + .post('/api/v1/books') + .send({ author: 'Someone' }) + .expect(400); + }); + + test('PUT /api/v1/books/:id updates a book', async () => { + const res = await request(app) + .put('/api/v1/books/1') + .send({ title: 'The Great Gatsby (Updated)', year: 1925 }) + .expect(200); + expect(res.body.book.title).toBe('The Great Gatsby (Updated)'); + }); + + test('DELETE /api/v1/books/:id removes book', async () => { + await request(app).delete('/api/v1/books/1').expect(200); + await request(app).get('/api/v1/books/1').expect(404); + }); + + test('GET /health returns ok', async () => { + const res = await request(app).get('/health').expect(200); + expect(res.body.status).toBe('ok'); + }); +}); diff --git a/tests/integration/transactions.test.js b/tests/integration/transactions.test.js deleted file mode 100644 index 6901f89..0000000 --- a/tests/integration/transactions.test.js +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Transaction Routes Integration Tests - */ - -const request = require('supertest'); -const express = require('express'); -const transactionRoutes = require('../../src/routes/transactions'); - -// Mock auth middleware -jest.mock('../../src/middleware/auth', () => ({ - validateApiKey: (req, res, next) => { - req.apiKey = req.headers['x-api-key'] || 'test-key'; - next(); - } -})); - -const db = require('../../src/database/db'); - -// Create fresh database for each test -beforeEach(() => { - // Clear and reinitialize - db.accounts.clear(); - db.transactions.clear(); - db.apiKeys.clear(); - db.apiKeys.add('1234'); // Re-add default API key - db.initializeSampleData(); -}); - -// Create test app -const app = express(); -app.use(express.json()); -app.use('/api/v1/transactions', transactionRoutes); - -describe('Transaction Routes', () => { - describe('GET /api/v1/transactions', () => { - test('should return all transactions', async () => { - const response = await request(app) - .get('/api/v1/transactions') - .set('x-api-key', 'test-key') - .expect(200); - - expect(response.body).toHaveProperty('transactions'); - expect(Array.isArray(response.body.transactions)).toBe(true); - }); - - test('should filter transactions by fromAccountId', async () => { - const response = await request(app) - .get('/api/v1/transactions?fromAccountId=1') - .set('x-api-key', 'test-key') - .expect(200); - - expect(Array.isArray(response.body.transactions)).toBe(true); - response.body.transactions.forEach(tx => { - expect(tx.fromAccountId).toBe('1'); - }); - }); - - test('should filter transactions by toAccountId', async () => { - const response = await request(app) - .get('/api/v1/transactions?toAccountId=2') - .set('x-api-key', 'test-key') - .expect(200); - - expect(Array.isArray(response.body.transactions)).toBe(true); - response.body.transactions.forEach(tx => { - expect(tx.toAccountId).toBe('2'); - }); - }); - - test('should filter transactions by createdAt', async () => { - const response = await request(app) - .get('/api/v1/transactions?createdAt=2024-01-10') - .set('x-api-key', 'test-key') - .expect(200); - - expect(Array.isArray(response.body.transactions)).toBe(true); - response.body.transactions.forEach(tx => { - expect(tx.createdAt).toBe('2024-01-10'); - }); - }); - - test('should return empty array for non-matching filters', async () => { - const response = await request(app) - .get('/api/v1/transactions?fromAccountId=999') - .set('x-api-key', 'test-key') - .expect(200); - - expect(response.body.transactions).toEqual([]); - }); - }); - - describe('GET /api/v1/transactions/:transactionId', () => { - test('should return a specific transaction', async () => { - const response = await request(app) - .get('/api/v1/transactions/1') - .set('x-api-key', 'test-key') - .expect(200); - - expect(response.body).toHaveProperty('transaction'); - expect(response.body.transaction.transactionId).toBe('1'); - expect(response.body.transaction).toHaveProperty('amount'); - expect(response.body.transaction).toHaveProperty('currency'); - expect(response.body.transaction).toHaveProperty('fromAccountId'); - expect(response.body.transaction).toHaveProperty('toAccountId'); - }); - - test('should return 404 for non-existent transaction', async () => { - const response = await request(app) - .get('/api/v1/transactions/999') - .set('x-api-key', 'test-key') - .expect(404); - - expect(response.body.error.name).toBe('instanceNotFoundError'); - }); - }); - - describe('POST /api/v1/transactions', () => { - test('should create a transfer transaction', async () => { - const transaction = { - fromAccountId: '1', - toAccountId: '2', - amount: 500, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(201); - - expect(response.body).toHaveProperty('transaction'); - expect(response.body.transaction).toHaveProperty('transactionId'); - }); - - test('should update balances after transfer', async () => { - // Get initial balances - const account1Before = db.getAccountById('1'); - const account2Before = db.getAccountById('2'); - const initialBalance1 = account1Before.balance; - const initialBalance2 = account2Before.balance; - - const transaction = { - fromAccountId: '1', - toAccountId: '2', - amount: 500, - currency: 'COSMIC_COINS' - }; - - await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(201); - - // Check updated balances - const account1After = db.getAccountById('1'); - const account2After = db.getAccountById('2'); - - expect(account1After.balance).toBe(initialBalance1 - 500); - expect(account2After.balance).toBe(initialBalance2 + 500); - }); - - test('should create a deposit transaction', async () => { - const transaction = { - fromAccountId: '0', - toAccountId: '2', - amount: 1000, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(201); - - expect(response.body.transaction).toHaveProperty('transactionId'); - }); - - test('should update balance after deposit', async () => { - const accountBefore = db.getAccountById('2'); - const initialBalance = accountBefore.balance; - - const transaction = { - fromAccountId: '0', - toAccountId: '2', - amount: 1000, - currency: 'COSMIC_COINS' - }; - - await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(201); - - const accountAfter = db.getAccountById('2'); - expect(accountAfter.balance).toBe(initialBalance + 1000); - }); - - test('should reject transaction with insufficient funds', async () => { - const transaction = { - fromAccountId: '1', - toAccountId: '2', - amount: 999999999, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(403); - - expect(response.body.error.name).toBe('txInsufficientFunds'); - }); - - test('should reject transaction with non-existent source account', async () => { - const transaction = { - fromAccountId: '999', - toAccountId: '2', - amount: 500, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(404); - - expect(response.body.error.name).toBe('instanceNotFoundError'); - }); - - test('should reject transaction with non-existent destination account', async () => { - const transaction = { - fromAccountId: '1', - toAccountId: '999', - amount: 500, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(404); - - expect(response.body.error.name).toBe('instanceNotFoundError'); - }); - - test('should reject transaction with currency mismatch', async () => { - const transaction = { - fromAccountId: '1', - toAccountId: '2', - amount: 500, - currency: 'GALAXY_GOLD' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(400); - - expect(response.body.error.name).toBe('validationError'); - }); - - test('should reject transaction with missing amount', async () => { - const transaction = { - fromAccountId: '1', - toAccountId: '2', - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(400); - - expect(response.body.error.name).toBe('validationError'); - }); - - test('should reject transaction with zero amount', async () => { - const transaction = { - fromAccountId: '1', - toAccountId: '2', - amount: 0, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(400); - - expect(response.body.error.name).toBe('validationError'); - }); - - test('should reject transaction with negative amount', async () => { - const transaction = { - fromAccountId: '1', - toAccountId: '2', - amount: -500, - currency: 'COSMIC_COINS' - }; - - const response = await request(app) - .post('/api/v1/transactions') - .set('x-api-key', 'test-key') - .send(transaction) - .expect(400); - - expect(response.body.error.name).toBe('validationError'); - }); - }); -}); - diff --git a/tests/integration/workflows.test.js b/tests/integration/workflows.test.js deleted file mode 100644 index 10fb10c..0000000 --- a/tests/integration/workflows.test.js +++ /dev/null @@ -1,120 +0,0 @@ -/** - * End-to-End Workflow Integration Tests - * Tests complete user scenarios across multiple endpoints - */ - -const request = require('supertest'); -const express = require('express'); -const adminRoutes = require('../../src/routes/admin'); -const accountRoutes = require('../../src/routes/accounts'); -const transactionRoutes = require('../../src/routes/transactions'); - -// Mock auth middleware for testing -jest.mock('../../src/middleware/auth', () => ({ - validateApiKey: (req, res, next) => { - req.apiKey = req.headers['x-api-key'] || 'test-key'; - next(); - }, - requireAdmin: (req, res, next) => { - if (req.apiKey === 'admin-key') { - next(); - } else { - res.status(403).json({ - error: { - name: 'forbiddenError', - message: 'You do not have permissions to perform this action. Admin access required.' - } - }); - } - } -})); - -const db = require('../../src/database/db'); - -// Create fresh database for each test -beforeEach(() => { - db.accounts.clear(); - db.transactions.clear(); - db.apiKeys.clear(); - db.apiKeys.add('1234'); // Re-add default API key - db.initializeSampleData(); -}); - -// Create test app with all routes -const app = express(); -app.use(express.json()); -app.use('/api/v1', adminRoutes); -app.use('/api/v1/accounts', accountRoutes); -app.use('/api/v1/transactions', transactionRoutes); - -describe('End-to-End Workflows', () => { - describe('Error Handling Workflow', () => { - test('should handle currency mismatch across workflow', async () => { - const apiKey = 'test-key'; - - // Account 3 uses GALAXY_GOLD, try to transfer COSMIC_COINS - await request(app) - .post('/api/v1/transactions') - .set('x-api-key', apiKey) - .send({ - fromAccountId: '1', - toAccountId: '3', - amount: 100, - currency: 'COSMIC_COINS' - }) - .expect(400); - }); - }); - - describe('Query and Filter Workflow', () => { - test('should filter and query data across multiple endpoints', async () => { - const apiKey = 'test-key'; - - // Create multiple transactions - await request(app) - .post('/api/v1/transactions') - .set('x-api-key', apiKey) - .send({ - fromAccountId: '1', - toAccountId: '2', - amount: 100, - currency: 'COSMIC_COINS' - }); - - await request(app) - .post('/api/v1/transactions') - .set('x-api-key', apiKey) - .send({ - fromAccountId: '1', - toAccountId: '3', - amount: 200, - currency: 'COSMIC_COINS' - }); - - // Filter transactions by source account - const fromAccount1 = await request(app) - .get('/api/v1/transactions?fromAccountId=1') - .set('x-api-key', apiKey) - .expect(200); - - expect(fromAccount1.body.transactions.length).toBeGreaterThanOrEqual(2); - - // Filter accounts by date - const accountsByDate = await request(app) - .get('/api/v1/accounts?createdAt=2023-04-10') - .set('x-api-key', apiKey) - .expect(200); - - expect(accountsByDate.body.accounts.length).toBeGreaterThan(0); - - // Filter accounts by owner - const accountsByOwner = await request(app) - .get('/api/v1/accounts?owner=Nova') - .set('x-api-key', apiKey) - .expect(200); - - expect(accountsByOwner.body.accounts.length).toBeGreaterThan(0); - }); - }); -}); - diff --git a/tests/unit/middleware/auth.test.js b/tests/unit/middleware/auth.test.js deleted file mode 100644 index f39fa66..0000000 --- a/tests/unit/middleware/auth.test.js +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Authentication Middleware Tests - */ - -const { validateApiKey, requireAdmin } = require('../../../src/middleware/auth'); - -// Mock the database -jest.mock('../../../src/database/db', () => ({ - validateApiKey: jest.fn() -})); - -const db = require('../../../src/database/db'); - -describe('Authentication Middleware', () => { - let req, res, next; - - beforeEach(() => { - req = { - headers: {} - }; - res = { - status: jest.fn().mockReturnThis(), - json: jest.fn() - }; - next = jest.fn(); - jest.clearAllMocks(); - }); - - describe('validateApiKey()', () => { - test('should accept valid API key from x-api-key header', () => { - req.headers['x-api-key'] = 'valid-key'; - db.validateApiKey.mockReturnValue(true); - - validateApiKey(req, res, next); - - expect(db.validateApiKey).toHaveBeenCalledWith('valid-key'); - expect(req.apiKey).toBe('valid-key'); - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - }); - - test('should accept valid API key from api-key header', () => { - req.headers['api-key'] = 'valid-key'; - db.validateApiKey.mockReturnValue(true); - - validateApiKey(req, res, next); - - expect(db.validateApiKey).toHaveBeenCalledWith('valid-key'); - expect(req.apiKey).toBe('valid-key'); - expect(next).toHaveBeenCalled(); - }); - - test('should reject request with missing API key', () => { - validateApiKey(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ - error: { - name: 'authenticationError', - message: expect.stringContaining('API key is required') - } - }); - expect(next).not.toHaveBeenCalled(); - }); - - test('should reject request with invalid API key', () => { - req.headers['x-api-key'] = 'invalid-key'; - db.validateApiKey.mockReturnValue(false); - - validateApiKey(req, res, next); - - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ - error: { - name: 'authenticationError', - message: expect.stringContaining('Invalid API key') - } - }); - expect(next).not.toHaveBeenCalled(); - }); - }); - - describe('requireAdmin()', () => { - test('should allow admin with correct API key', () => { - req.apiKey = process.env.ADMIN_API_KEY || '1234'; - - requireAdmin(req, res, next); - - expect(next).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalled(); - }); - - test('should reject non-admin user', () => { - req.apiKey = 'regular-user-key'; - - requireAdmin(req, res, next); - - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ - error: { - name: 'forbiddenError', - message: expect.stringContaining('do not have permissions') - } - }); - expect(next).not.toHaveBeenCalled(); - }); - - test('should use default admin key if env variable not set', () => { - const originalAdminKey = process.env.ADMIN_API_KEY; - delete process.env.ADMIN_API_KEY; - - req.apiKey = '1234'; - - requireAdmin(req, res, next); - - expect(next).toHaveBeenCalled(); - - // Restore - process.env.ADMIN_API_KEY = originalAdminKey; - }); - }); -}); - diff --git a/tests/unit/models/Account.test.js b/tests/unit/models/Account.test.js deleted file mode 100644 index afa69c9..0000000 --- a/tests/unit/models/Account.test.js +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Account Model Tests - */ - -const Account = require('../../../src/models/Account'); - -describe('Account Model', () => { - describe('Constructor', () => { - test('should create an account with all properties', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS', '2024-01-10'); - - expect(account.accountId).toBe('1'); - expect(account.owner).toBe('John Doe'); - expect(account.balance).toBe(1000); - expect(account.currency).toBe('COSMIC_COINS'); - expect(account.createdAt).toBe('2024-01-10'); - }); - - test('should create an account with default balance', () => { - const account = new Account('1', 'John Doe', undefined, 'COSMIC_COINS'); - - expect(account.balance).toBe(0); - }); - - test('should create an account with default createdAt date', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - expect(account.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - }); - - describe('validate()', () => { - test('should validate a correct account', () => { - const data = { - owner: 'John Doe', - balance: 1000, - currency: 'COSMIC_COINS' - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(true); - }); - - test('should reject missing owner', () => { - const data = { - balance: 1000, - currency: 'COSMIC_COINS' - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('Owner'); - }); - - test('should reject non-string owner', () => { - const data = { - owner: 123, - balance: 1000, - currency: 'COSMIC_COINS' - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('string'); - }); - - test('should reject missing currency', () => { - const data = { - owner: 'John Doe', - balance: 1000 - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('Currency'); - }); - - test('should reject invalid currency', () => { - const data = { - owner: 'John Doe', - balance: 1000, - currency: 'INVALID_CURRENCY' - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('Currency'); - }); - - test('should accept valid currencies', () => { - const currencies = ['COSMIC_COINS', 'GALAXY_GOLD', 'MOON_BUCKS']; - - currencies.forEach(currency => { - const data = { - owner: 'John Doe', - currency: currency - }; - - const result = Account.validate(data); - expect(result.isValid).toBe(true); - }); - }); - - test('should reject negative balance', () => { - const data = { - owner: 'John Doe', - balance: -100, - currency: 'COSMIC_COINS' - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('Balance'); - }); - - test('should reject non-number balance', () => { - const data = { - owner: 'John Doe', - balance: '1000', - currency: 'COSMIC_COINS' - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('number'); - }); - - test('should accept zero balance', () => { - const data = { - owner: 'John Doe', - balance: 0, - currency: 'COSMIC_COINS' - }; - - const result = Account.validate(data); - - expect(result.isValid).toBe(true); - }); - }); - - describe('updateBalance()', () => { - test('should add to balance with positive amount', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - account.updateBalance(500); - - expect(account.balance).toBe(1500); - }); - - test('should subtract from balance with negative amount', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - account.updateBalance(-300); - - expect(account.balance).toBe(700); - }); - - test('should handle zero amount', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - account.updateBalance(0); - - expect(account.balance).toBe(1000); - }); - }); - - describe('hasSufficientFunds()', () => { - test('should return true when balance is sufficient', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - expect(account.hasSufficientFunds(500)).toBe(true); - }); - - test('should return true when amount equals balance', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - expect(account.hasSufficientFunds(1000)).toBe(true); - }); - - test('should return false when balance is insufficient', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - expect(account.hasSufficientFunds(1500)).toBe(false); - }); - - test('should return true for zero amount', () => { - const account = new Account('1', 'John Doe', 0, 'COSMIC_COINS'); - - expect(account.hasSufficientFunds(0)).toBe(true); - }); - }); - - describe('toJSON()', () => { - test('should return correct JSON representation', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS', '2024-01-10'); - - const json = account.toJSON(); - - expect(json).toEqual({ - accountId: '1', - owner: 'John Doe', - createdAt: '2024-01-10', - balance: 1000, - currency: 'COSMIC_COINS' - }); - }); - - test('should include all required properties', () => { - const account = new Account('1', 'John Doe', 1000, 'COSMIC_COINS'); - - const json = account.toJSON(); - - expect(json).toHaveProperty('accountId'); - expect(json).toHaveProperty('owner'); - expect(json).toHaveProperty('createdAt'); - expect(json).toHaveProperty('balance'); - expect(json).toHaveProperty('currency'); - }); - }); -}); - diff --git a/tests/unit/models/Book.test.js b/tests/unit/models/Book.test.js new file mode 100644 index 0000000..53aa7a0 --- /dev/null +++ b/tests/unit/models/Book.test.js @@ -0,0 +1,25 @@ +const Book = require('../../../src/models/Book'); + +describe('Book model', () => { + test('validate accepts valid data', () => { + expect(Book.validate({ title: 'Dune', author: 'Frank Herbert', year: 1965 })).toEqual({ isValid: true }); + expect(Book.validate({ title: 'X', author: 'Y' })).toEqual({ isValid: true }); + }); + + test('validate rejects missing title', () => { + const r = Book.validate({ author: 'Someone' }); + expect(r.isValid).toBe(false); + expect(r.error).toContain('Title'); + }); + + test('validate rejects missing author', () => { + const r = Book.validate({ title: 'Something' }); + expect(r.isValid).toBe(false); + expect(r.error).toContain('Author'); + }); + + test('toJSON returns plain object', () => { + const book = new Book('1', 'Dune', 'Frank Herbert', 1965); + expect(book.toJSON()).toEqual({ id: '1', title: 'Dune', author: 'Frank Herbert', year: 1965 }); + }); +}); diff --git a/tests/unit/models/Transaction.test.js b/tests/unit/models/Transaction.test.js deleted file mode 100644 index 4ded7fb..0000000 --- a/tests/unit/models/Transaction.test.js +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Transaction Model Tests - */ - -const Transaction = require('../../../src/models/Transaction'); - -describe('Transaction Model', () => { - describe('Constructor', () => { - test('should create a transaction with all properties', () => { - const transaction = new Transaction( - '1', - 'acc1', - 'acc2', - 1000, - 'COSMIC_COINS', - '2024-01-10' - ); - - expect(transaction.transactionId).toBe('1'); - expect(transaction.fromAccountId).toBe('acc1'); - expect(transaction.toAccountId).toBe('acc2'); - expect(transaction.amount).toBe(1000); - expect(transaction.currency).toBe('COSMIC_COINS'); - expect(transaction.createdAt).toBe('2024-01-10'); - }); - - test('should create a transaction with default createdAt', () => { - const transaction = new Transaction( - '1', - 'acc1', - 'acc2', - 1000, - 'COSMIC_COINS' - ); - - expect(transaction.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}$/); - }); - }); - - describe('validate()', () => { - test('should validate a correct transaction', () => { - const data = { - fromAccountId: 'acc1', - toAccountId: 'acc2', - amount: 1000, - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(true); - }); - - test('should validate a deposit transaction (fromAccountId = "0")', () => { - const data = { - fromAccountId: '0', - toAccountId: 'acc2', - amount: 1000, - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(true); - }); - - test('should reject missing fromAccountId', () => { - const data = { - toAccountId: 'acc2', - amount: 1000, - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('fromAccountId'); - }); - - test('should reject missing toAccountId', () => { - const data = { - fromAccountId: 'acc1', - amount: 1000, - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('toAccountId'); - }); - - test('should reject missing amount', () => { - const data = { - fromAccountId: 'acc1', - toAccountId: 'acc2', - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('Amount'); - }); - - test('should reject zero amount', () => { - const data = { - fromAccountId: 'acc1', - toAccountId: 'acc2', - amount: 0, - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('positive'); - }); - - test('should reject negative amount', () => { - const data = { - fromAccountId: 'acc1', - toAccountId: 'acc2', - amount: -500, - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('positive'); - }); - - test('should reject non-number amount', () => { - const data = { - fromAccountId: 'acc1', - toAccountId: 'acc2', - amount: '1000', - currency: 'COSMIC_COINS' - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('number'); - }); - - test('should reject missing currency', () => { - const data = { - fromAccountId: 'acc1', - toAccountId: 'acc2', - amount: 1000 - }; - - const result = Transaction.validate(data); - - expect(result.isValid).toBe(false); - expect(result.error).toContain('Currency'); - }); - }); - - describe('isDeposit()', () => { - test('should return true for deposit transactions', () => { - const transaction = new Transaction( - '1', - '0', - 'acc2', - 1000, - 'COSMIC_COINS' - ); - - expect(transaction.isDeposit()).toBe(true); - }); - - test('should return false for transfer transactions', () => { - const transaction = new Transaction( - '1', - 'acc1', - 'acc2', - 1000, - 'COSMIC_COINS' - ); - - expect(transaction.isDeposit()).toBe(false); - }); - }); - - describe('toJSON()', () => { - test('should return correct JSON representation', () => { - const transaction = new Transaction( - '1', - 'acc1', - 'acc2', - 1000, - 'COSMIC_COINS', - '2024-01-10' - ); - - const json = transaction.toJSON(); - - expect(json).toEqual({ - transactionId: '1', - createdAt: '2024-01-10', - amount: 1000, - currency: 'COSMIC_COINS', - fromAccountId: 'acc1', - toAccountId: 'acc2' - }); - }); - - test('should include all required properties', () => { - const transaction = new Transaction( - '1', - 'acc1', - 'acc2', - 1000, - 'COSMIC_COINS' - ); - - const json = transaction.toJSON(); - - expect(json).toHaveProperty('transactionId'); - expect(json).toHaveProperty('createdAt'); - expect(json).toHaveProperty('amount'); - expect(json).toHaveProperty('currency'); - expect(json).toHaveProperty('fromAccountId'); - expect(json).toHaveProperty('toAccountId'); - }); - }); -}); -