mirror of
https://github.com/andwati/order-service.git
synced 2026-10-18 10:22:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e068281725 | ||
|
|
57fa3e8a94 | ||
|
|
2b07083d62 | ||
|
|
86b9ef8dec | ||
|
|
aa38999ff4 | ||
|
|
fc42bff882 | ||
|
|
dd92d8eaae | ||
|
|
e8618b7c98 | ||
|
|
e52ab793c5 | ||
|
|
9028465bcc | ||
|
|
ef5703a5ed | ||
|
|
1d68dc0c0a | ||
|
|
ba21363eae | ||
|
|
b74197f60f | ||
|
|
8e385887de | ||
|
|
d2334181a0 |
@@ -1 +1,338 @@
|
||||
an order service with authentication,data persistence etc
|
||||
# Order Service
|
||||
|
||||
An **order management REST API** with authentication, role-based access control, and MongoDB persistence. It exposes endpoints for:
|
||||
|
||||
- **Auth** – register/login, issue JWTs
|
||||
- **Products** – admin-only create/update, public list
|
||||
- **Orders** – customers create/list/pay/cancel orders
|
||||
- **Users** – admin-only user listing
|
||||
|
||||
Built with **Node.js**, **Express 5**, **TypeScript**, and **Mongoose**.
|
||||
|
||||
Swagger doc for test in the browser: <https://order-service-t77z.onrender.com/docs/>
|
||||
|
||||
---
|
||||
|
||||
## 1. Setup & Installation
|
||||
|
||||
- **Prerequisites**
|
||||
- **Node.js**: v20+ recommended
|
||||
- **Package manager**: `pnpm` (project uses `pnpm-lock.yaml`)
|
||||
- **MongoDB**: running locally or accessible via connection string
|
||||
|
||||
- **Clone and install**
|
||||
|
||||
```bash
|
||||
git clone <your-fork-or-repo-url> order-service
|
||||
cd order-service
|
||||
pnpm install
|
||||
```
|
||||
|
||||
- **Build the project**
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
- **Start the server**
|
||||
|
||||
By default the compiled server entrypoint is `dist/server.js`:
|
||||
|
||||
```bash
|
||||
node dist/server.js
|
||||
```
|
||||
|
||||
The API will listen on `http://localhost:<PORT>` (defaults to `3000`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Environment Variables
|
||||
|
||||
Environment variables are loaded via `dotenv` in `src/config/env.ts`. The following variables are **required/recommended**:
|
||||
|
||||
- **Required**
|
||||
- **`MONGO_URI`**: MongoDB connection string
|
||||
Example: `mongodb://localhost:27017/order_service`
|
||||
- **`JWT_SECRET`**: Secret key used to sign JWT access tokens
|
||||
|
||||
- **Optional**
|
||||
- **`PORT`**: HTTP port to run the server on
|
||||
Default: `3000`
|
||||
- **`NODE_ENV`**: `development` | `production`
|
||||
Default: `development`
|
||||
- **`API_URL`**: External base URL for the API
|
||||
Default: `http://localhost:3000`
|
||||
- **`JWT_EXPIRES_IN`**: Token lifetime, e.g. `1h`, `7d`
|
||||
Default: `1h`
|
||||
|
||||
- **Example `.env`**
|
||||
|
||||
```bash
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
API_URL=http://localhost:3000
|
||||
|
||||
MONGO_URI=mongodb://localhost:27017/order_service
|
||||
|
||||
JWT_SECRET=super-secret-change-me
|
||||
JWT_EXPIRES_IN=1h
|
||||
```
|
||||
|
||||
Create a `.env` file at the project root before running the server or scripts.
|
||||
|
||||
---
|
||||
|
||||
## 3. Database, Migrations & Seeding
|
||||
|
||||
This project uses **MongoDB + Mongoose**. There is no formal migration tool configured; schemas are defined via Mongoose models under `src/models`.
|
||||
|
||||
- **Database connection**
|
||||
- Configured in `src/config/db.ts` using `MONGO_URI`.
|
||||
|
||||
- **Seeding products**
|
||||
|
||||
There is a seed script at `src/scripts/seed.ts` which:
|
||||
|
||||
- Connects to MongoDB
|
||||
- Clears the `products` collection
|
||||
- Inserts a fixed list of sample products (mice, keyboards, etc.)
|
||||
|
||||
**Run the seed script:**
|
||||
|
||||
```bash
|
||||
pnpm run seed
|
||||
```
|
||||
|
||||
Under the hood this will:
|
||||
|
||||
1. Compile TypeScript (`tsc`)
|
||||
2. Run `node dist/scripts/seed.js`
|
||||
|
||||
Make sure:
|
||||
|
||||
- The project is built without errors.
|
||||
- `MONGO_URI` is correctly set in your `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Running the Service
|
||||
|
||||
- **Build once:**
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
- **Start server:**
|
||||
|
||||
```bash
|
||||
node dist/server.js
|
||||
```
|
||||
|
||||
The service will:
|
||||
|
||||
- Connect to MongoDB.
|
||||
- Expose health check at `GET /health`.
|
||||
- Expose REST endpoints under `/auth`, `/products`, `/orders`, `/users`.
|
||||
- Expose Swagger docs under `/docs`.
|
||||
|
||||
> Note: For local development you can also use `ts-node` / `nodemon` if you wire custom scripts, but for this task the build‑then‑run flow above is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## 5. API Reference & Example `curl` Requests
|
||||
|
||||
## 5.1 Health Check
|
||||
|
||||
- **GET `/health`**
|
||||
|
||||
```bash
|
||||
curl -i https://order-service-t77z.onrender.com/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.2 Auth
|
||||
|
||||
### Register
|
||||
|
||||
- **POST `/auth/register`**
|
||||
- Body:
|
||||
|
||||
```bash
|
||||
curl -i -X POST https://order-service-t77z.onrender.com/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "[email protected]",
|
||||
"password": "Password123",
|
||||
"role": "customer"
|
||||
}'
|
||||
```
|
||||
|
||||
For an admin user, set `"role": "admin"`.
|
||||
|
||||
### Login
|
||||
|
||||
- **POST `/auth/login`**
|
||||
|
||||
```bash
|
||||
curl -i -X POST https://order-service-t77z.onrender.com/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "[email protected]",
|
||||
"password": "Password123"
|
||||
}'
|
||||
```
|
||||
|
||||
The response includes a `token` field (JWT). Use this token in subsequent requests:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.3 Products
|
||||
|
||||
### List products (public)
|
||||
|
||||
- **GET `/products`**
|
||||
|
||||
```bash
|
||||
curl -i https://order-service-t77z.onrender.com/products
|
||||
```
|
||||
|
||||
### Create product (admin only)
|
||||
|
||||
- **POST `/products`**
|
||||
|
||||
```bash
|
||||
curl -i -X POST https://order-service-t77z.onrender.com/products \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
-d '{
|
||||
"name": "New Gadget",
|
||||
"price": 1999,
|
||||
"stock": 25
|
||||
}'
|
||||
```
|
||||
|
||||
### Update product (admin only)
|
||||
|
||||
- **PATCH `/products/:id`**
|
||||
|
||||
```bash
|
||||
curl -i -X PATCH https://order-service-t77z.onrender.com/products/<productId> \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
-d '{
|
||||
"price": 2499,
|
||||
"stock": 40
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.4 Orders
|
||||
|
||||
### Create order (customer or admin)
|
||||
|
||||
- **POST `/orders`**
|
||||
|
||||
```bash
|
||||
curl -i -X POST https://order-service-t77z.onrender.com/orders \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{
|
||||
"items": [
|
||||
{ "productId": "<productId>", "quantity": 2 },
|
||||
{ "productId": "<otherProductId>", "quantity": 1 }
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### List my orders
|
||||
|
||||
- **GET `/orders`**
|
||||
|
||||
```bash
|
||||
curl -i https://order-service-t77z.onrender.com/orders \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Pay for an order
|
||||
|
||||
- **POST `/orders/:id/pay`**
|
||||
|
||||
```bash
|
||||
curl -i -X POST https://order-service-t77z.onrender.com/orders/<orderId>/pay \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Cancel an order
|
||||
|
||||
- **POST `/orders/:id/cancel`**
|
||||
|
||||
```bash
|
||||
curl -i -X POST https://order-service-t77z.onrender.com/orders/<orderId>/cancel \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.5 Users (Admin only)
|
||||
|
||||
### List users
|
||||
|
||||
- **GET `/users`**
|
||||
|
||||
```bash
|
||||
curl -i https://order-service-t77z.onrender.com/users \
|
||||
-H "Authorization: Bearer <admin-token>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Swagger / API Docs
|
||||
|
||||
The service is wired with `swagger-jsdoc` and `swagger-ui-express`. OpenAPI annotations live in the route files (e.g. `src/routes/auth.routes.ts`, `src/routes/order.routes.ts`, etc) and are aggregated in the Swagger configuration.
|
||||
|
||||
- **View documentation UI:**
|
||||
|
||||
```bash
|
||||
open https://order-service-t77z.onrender.com/docs
|
||||
```
|
||||
|
||||
This provides an interactive UI for exploring and testing all endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 7. Design Notes
|
||||
|
||||
- **Architecture**
|
||||
- **Layered structure**:
|
||||
- `controllers/`: Express route handlers (request/response orchestration).
|
||||
- `services/`: Business logic (auth, orders, products, users).
|
||||
- `repositories/`: Data access layer over Mongoose models.
|
||||
- `models/`: Mongoose schemas and models.
|
||||
- `middleware/`: Cross-cutting concerns (auth, validation, error handling, rate limiting, role checks).
|
||||
- **Separation of concerns** keeps controllers thin and business logic testable.
|
||||
|
||||
- **Authentication & Authorization**
|
||||
- JWT-based auth (see `auth.middleware.ts` and `utils/jwt.ts`).
|
||||
- Role-based access control via `requireRole` middleware:
|
||||
- Customers can create/list/pay/cancel their own orders.
|
||||
- Admins can manage products and list all users.
|
||||
|
||||
- **Validation & Error Handling**
|
||||
- **Zod** schemas validate payloads (`validation/*.schema.ts`).
|
||||
- Central `error.middleware.ts` normalizes API error responses.
|
||||
- `rateLimit.middleware.ts` limits sensitive endpoints (e.g. auth) to mitigate brute-force.
|
||||
|
||||
- **Persistence**
|
||||
- MongoDB via Mongoose models (`user.model.ts`, `product.model.ts`, `order.model.ts`).
|
||||
- Order items reference products and store quantities and pricing data for consistency.
|
||||
|
||||
- **Observability & DX**
|
||||
- `morgan` + custom `logger` provide structured request logs.
|
||||
- TypeScript configuration (`tsconfig.json`) and linting (`eslint.config.mts`) encourage a consistent, safe codebase.
|
||||
|
||||
+21
-13
@@ -10,7 +10,11 @@
|
||||
"lint": "eslint . --ext .ts",
|
||||
"lint:fix": "eslint . --ext .ts --fix",
|
||||
"prettier": "prettier --check .",
|
||||
"prettier:fix": "prettier --write ."
|
||||
"prettier:fix": "prettier --write .",
|
||||
"seed": "tsc && node dist/scripts/seed.js",
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest run src/tests/unit",
|
||||
"test:integration": "vitest run src/tests/integration"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -18,19 +22,19 @@
|
||||
"packageManager": "[email protected]",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"mongoose": "^9.1.3",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"mongoose": "^9.3.3",
|
||||
"morgan": "^1.10.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/json": "^0.14.0",
|
||||
"@eslint/markdown": "^7.5.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -38,16 +42,20 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.8",
|
||||
"eslint": "^9.39.2",
|
||||
"globals": "^17.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"eslint": "^9.39.4",
|
||||
"globals": "^17.4.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nodemon": "^3.1.11",
|
||||
"prettier": "^3.7.4",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "^3.8.1",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.52.0"
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
Generated
+1471
-304
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@scarf/scarf'
|
||||
- bcrypt
|
||||
- esbuild
|
||||
|
||||
+7
-1
@@ -4,9 +4,11 @@ import cors from "cors";
|
||||
import morgan from "morgan";
|
||||
|
||||
import { errorHandler } from "./middleware/error.middleware.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
import authRoutes from "./routes/auth.routes.js";
|
||||
import productRoutes from "./routes/product.routes.js";
|
||||
import orderRoutes from "./routes/order.routes.js";
|
||||
import userRoutes from "./routes/user.routes.js";
|
||||
import docsRoutes from "./routes/docs.routes.js";
|
||||
|
||||
const app: Application = express();
|
||||
@@ -14,7 +16,10 @@ const app: Application = express();
|
||||
//global middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(morgan("dev"));
|
||||
|
||||
// HTTP request logging with morgan
|
||||
const morganFormat = ":method :url :status :res[content-length] - :response-time ms";
|
||||
app.use(morgan(morganFormat, { stream: logger.stream }));
|
||||
|
||||
// health check route
|
||||
app.get("/health", (req, res) => {
|
||||
@@ -25,6 +30,7 @@ app.get("/health", (req, res) => {
|
||||
app.use("/auth", authRoutes);
|
||||
app.use("/products", productRoutes);
|
||||
app.use("/orders", orderRoutes);
|
||||
app.use("/users", userRoutes);
|
||||
// swagger ui
|
||||
app.use("/docs", docsRoutes);
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { AuthService } from "../services/auth.service.js";
|
||||
export class AuthController {
|
||||
static async register(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const { email, password, role } = req.body;
|
||||
|
||||
const user = await AuthService.register(email, password);
|
||||
const user = await AuthService.register(email, password, role);
|
||||
|
||||
res.status(201).json(user);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { UserService } from "../services/user.service.js";
|
||||
|
||||
export class UserController {
|
||||
static async getAll(_req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const users = await UserService.getAll();
|
||||
res.status(200).json(users);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
export function requireRole(role: "admin" | "customer") {
|
||||
export function requireRole(roles: "admin" | "customer" | ("admin" | "customer")[]) {
|
||||
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
||||
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
@@ -9,7 +11,7 @@ export function requireRole(role: "admin" | "customer") {
|
||||
});
|
||||
}
|
||||
|
||||
if (req.user.role !== role) {
|
||||
if (!allowedRoles.includes(req.user.role as "admin" | "customer")) {
|
||||
return next({
|
||||
statusCode: 403,
|
||||
message: "Forbidden",
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import type { ZodSchema } from "zod";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
interface ParsedRequest {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, string>;
|
||||
query?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const validate =
|
||||
(schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => {
|
||||
try {
|
||||
schema.parse({
|
||||
const parsed = schema.parse({
|
||||
body: req.body,
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
});
|
||||
}) as ParsedRequest;
|
||||
|
||||
// Update request with parsed values (including defaults)
|
||||
if (parsed.body) req.body = parsed.body;
|
||||
if (parsed.params) req.params = parsed.params;
|
||||
if (parsed.query) req.query = parsed.query as Record<string, string>;
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { UserModel } from "../models/user.model.js";
|
||||
import type { UserDocument } from "../models/user.model.js";
|
||||
|
||||
export class UserRepository {
|
||||
static findAll(): Promise<UserDocument[]> {
|
||||
return UserModel.find().select("-passwordHash");
|
||||
}
|
||||
|
||||
static findByEmail(email: string): Promise<UserDocument | null> {
|
||||
return UserModel.findOne({ email });
|
||||
}
|
||||
|
||||
@@ -13,22 +13,52 @@ const router: Router = Router();
|
||||
* tags:
|
||||
* - Auth
|
||||
* summary: Register a new user
|
||||
* security: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* description: User's email address
|
||||
* password:
|
||||
* type: string
|
||||
* minLength: 8
|
||||
* description: User's password (minimum 8 characters)
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [customer, admin]
|
||||
* default: customer
|
||||
* description: Account type (defaults to customer)
|
||||
* responses:
|
||||
* 201:
|
||||
* description: User created
|
||||
* description: User created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: The user ID
|
||||
* email:
|
||||
* type: string
|
||||
* description: The user's email
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [customer, admin]
|
||||
* description: The user's role
|
||||
* 400:
|
||||
* description: Invalid input
|
||||
* 409:
|
||||
* description: Email already registered
|
||||
*/
|
||||
router.post(
|
||||
"/register",
|
||||
|
||||
@@ -44,7 +44,7 @@ const router: Router = Router();
|
||||
router.post(
|
||||
"/",
|
||||
authMiddleware,
|
||||
requireRole("customer"),
|
||||
requireRole(["customer", "admin"]),
|
||||
validate(createOrderSchema),
|
||||
OrderController.create,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Router } from "express";
|
||||
import { UserController } from "../controllers/user.controller.js";
|
||||
import { authMiddleware } from "../middleware/auth.middleware.js";
|
||||
import { requireRole } from "../middleware/role.middleware.js";
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users:
|
||||
* get:
|
||||
* tags:
|
||||
* - Users
|
||||
* summary: Get all users (Admin only)
|
||||
* description: Retrieve a list of all registered users. This endpoint is restricted to administrators only.
|
||||
* security:
|
||||
* - BearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of users retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: The user ID
|
||||
* email:
|
||||
* type: string
|
||||
* description: The user's email
|
||||
* role:
|
||||
* type: string
|
||||
* enum: [customer, admin]
|
||||
* description: The user's role
|
||||
* createdAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Account creation date
|
||||
* updatedAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Last update date
|
||||
* 401:
|
||||
* description: Unauthorized - Missing or invalid token
|
||||
* 403:
|
||||
* description: Forbidden - Admin access required
|
||||
*/
|
||||
router.get("/", authMiddleware, requireRole("admin"), UserController.getAll);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { connectDB } from "../config/db.js";
|
||||
import { ProductModel } from "../models/product.model.js";
|
||||
import mongoose from "mongoose";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
const PRODUCTS = [
|
||||
{
|
||||
name: "Wireless Mouse",
|
||||
price: 2500, // $25.00
|
||||
stock: 100,
|
||||
},
|
||||
{
|
||||
name: "Mechanical Keyboard",
|
||||
price: 12000, // $120.00
|
||||
stock: 50,
|
||||
},
|
||||
{
|
||||
name: "USB-C Hub",
|
||||
price: 4500, // $45.00
|
||||
stock: 75,
|
||||
},
|
||||
{
|
||||
name: "Monitor Stand",
|
||||
price: 3500, // $35.00
|
||||
stock: 200,
|
||||
},
|
||||
{
|
||||
name: "Gaming Headset",
|
||||
price: 8000, // $80.00
|
||||
stock: 30,
|
||||
}
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
try {
|
||||
logger.info("Connecting to database...");
|
||||
await connectDB();
|
||||
logger.info("Connected to database.");
|
||||
|
||||
logger.info("Clearing products...");
|
||||
await ProductModel.deleteMany({});
|
||||
logger.info("Products cleared.");
|
||||
|
||||
logger.info("Seeding products...");
|
||||
await ProductModel.insertMany(PRODUCTS);
|
||||
logger.info("Products seeded successfully.");
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error("Error seeding database:", { error });
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
@@ -1,9 +1,10 @@
|
||||
import { UserRepository } from "../repositories/user.repo.js";
|
||||
import { hashPassword, verifyPassword } from "../utils/password.js";
|
||||
import { signToken } from "../utils/jwt.js";
|
||||
import type { UserRole } from "../models/user.model.js";
|
||||
|
||||
export class AuthService {
|
||||
static async register(email: string, password: string) {
|
||||
static async register(email: string, password: string, role: UserRole = "customer") {
|
||||
const existing = await UserRepository.findByEmail(email);
|
||||
if (existing) {
|
||||
throw { statusCode: 409, message: "Email already registered" };
|
||||
@@ -14,7 +15,7 @@ export class AuthService {
|
||||
const user = await UserRepository.create({
|
||||
email,
|
||||
passwordHash,
|
||||
role: "customer",
|
||||
role,
|
||||
});
|
||||
|
||||
return { id: user._id, email: user.email, role: user.role };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { UserRepository } from "../repositories/user.repo.js";
|
||||
|
||||
export class UserService {
|
||||
static async getAll() {
|
||||
const users = await UserRepository.findAll();
|
||||
return users.map((user) => ({
|
||||
id: user._id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import request from "supertest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("GET /health", () => {
|
||||
it("returns 200 OK", async () => {
|
||||
// Minimal env required by app config during import
|
||||
process.env.MONGO_URI = process.env.MONGO_URI ?? "mongodb://localhost:27017/test";
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET ?? "test-secret";
|
||||
|
||||
const { default: app } = await import("../../app.js");
|
||||
|
||||
const res = await request(app).get("/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "OK" });
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { OrderService } from "../../services/order.service.js";
|
||||
import { OrderRepository } from "../../repositories/order.repo.js";
|
||||
import { ProductRepository } from "../../repositories/product.repo.js";
|
||||
|
||||
vi.mock("mongoose", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import("mongoose");
|
||||
|
||||
const startSession = vi.fn().mockResolvedValue({
|
||||
startTransaction: vi.fn(),
|
||||
commitTransaction: vi.fn(),
|
||||
abortTransaction: vi.fn(),
|
||||
endSession: vi.fn(),
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...(actual as any).default,
|
||||
startSession,
|
||||
},
|
||||
startSession,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../repositories/order.repo.js");
|
||||
vi.mock("../../repositories/product.repo.js");
|
||||
|
||||
describe("OrderService", () => {
|
||||
const mockedOrderRepo = vi.mocked(OrderRepository);
|
||||
const mockedProductRepo = vi.mocked(ProductRepository);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("listOrders", () => {
|
||||
it("lists all orders for admin role", async () => {
|
||||
const orders = [{ id: "1" }];
|
||||
// @ts-ignore
|
||||
mockedOrderRepo.findAll.mockResolvedValueOnce(orders as never);
|
||||
|
||||
const result = await OrderService.listOrders("user-id", "admin");
|
||||
|
||||
expect(mockedOrderRepo.findAll).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual(orders);
|
||||
});
|
||||
|
||||
it("lists user orders for non-admin role", async () => {
|
||||
const orders = [{ id: "2" }];
|
||||
// @ts-ignore
|
||||
mockedOrderRepo.findByUser.mockResolvedValueOnce(orders as never);
|
||||
|
||||
const result = await OrderService.listOrders("user-123", "customer");
|
||||
|
||||
expect(mockedOrderRepo.findByUser).toHaveBeenCalledWith("user-123");
|
||||
expect(result).toEqual(orders);
|
||||
});
|
||||
});
|
||||
|
||||
describe("payOrder", () => {
|
||||
it("throws 400 for invalid id", async () => {
|
||||
await expect(OrderService.payOrder(undefined)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+36
-4
@@ -1,13 +1,33 @@
|
||||
type LogMeta = Record<string, unknown>;
|
||||
|
||||
function format(level: string, message: string, meta?: LogMeta) {
|
||||
type LogLevel = "debug" | "info" | "warn" | "error" | "http";
|
||||
|
||||
const LOG_COLORS: Record<LogLevel, string> = {
|
||||
debug: "\x1b[36m", // cyan
|
||||
info: "\x1b[32m", // green
|
||||
warn: "\x1b[33m", // yellow
|
||||
error: "\x1b[31m", // red
|
||||
http: "\x1b[35m", // magenta
|
||||
};
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function format(level: LogLevel, message: string, meta?: LogMeta): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
return meta
|
||||
? `[${timestamp}] ${level.toUpperCase()} ${message} ${JSON.stringify(meta)}`
|
||||
: `[${timestamp}] ${level.toUpperCase()} ${message}`;
|
||||
const color = LOG_COLORS[level];
|
||||
const levelStr = `${color}${level.toUpperCase().padEnd(5)}${RESET}`;
|
||||
|
||||
const baseLog = `[${timestamp}] ${levelStr} ${message}`;
|
||||
return meta ? `${baseLog} ${JSON.stringify(meta)}` : baseLog;
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(message: string, meta?: LogMeta) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log(format("debug", message, meta));
|
||||
}
|
||||
},
|
||||
|
||||
info(message: string, meta?: LogMeta) {
|
||||
console.log(format("info", message, meta));
|
||||
},
|
||||
@@ -19,4 +39,16 @@ export const logger = {
|
||||
error(message: string, meta?: LogMeta) {
|
||||
console.error(format("error", message, meta));
|
||||
},
|
||||
|
||||
http(message: string, meta?: LogMeta) {
|
||||
console.log(format("http", message, meta));
|
||||
},
|
||||
|
||||
// Stream for morgan integration
|
||||
stream: {
|
||||
write(message: string) {
|
||||
// Remove trailing newline from morgan
|
||||
logger.http(message.trim());
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export const registerSchema = z.object({
|
||||
body: z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
role: z.enum(["customer", "admin"]).default("customer"),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["src/tests/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html"],
|
||||
reportsDirectory: "./coverage",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user