feat(devops,docs,verify): implement Phase 4 CI pipeline, OpenAPI synchronization, and E2E verification matrix
Some checks failed
Deploy Canina / deploy (push) Failing after 28s
Some checks failed
Deploy Canina / deploy (push) Failing after 28s
This commit is contained in:
parent
27abaa3e05
commit
ad3f149789
91
.github/workflows/ci.yml
vendored
Normal file
91
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,91 @@
|
||||
name: CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
backend-ci:
|
||||
name: Backend Build, Lint, Typecheck & Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install Backend Dependencies
|
||||
run: |
|
||||
cd backend
|
||||
npm ci
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: |
|
||||
cd backend
|
||||
npx prisma generate
|
||||
|
||||
- name: Typecheck Backend
|
||||
run: |
|
||||
cd backend
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Lint Backend
|
||||
run: |
|
||||
cd backend
|
||||
npm run lint
|
||||
|
||||
- name: Run Backend Unit Tests
|
||||
run: |
|
||||
cd backend
|
||||
npm run test
|
||||
env:
|
||||
JWT_ACCESS_SECRET: "test_access_secret_32_characters_minimum_entropy"
|
||||
JWT_REFRESH_SECRET: "test_refresh_secret_32_characters_minimum_entropy"
|
||||
|
||||
- name: Build Backend
|
||||
run: |
|
||||
cd backend
|
||||
npm run build
|
||||
|
||||
frontend-admin-ci:
|
||||
name: Admin Panel Build, Lint & Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/admin-panel/package-lock.json
|
||||
|
||||
- name: Install Admin Panel Dependencies
|
||||
run: |
|
||||
cd frontend/admin-panel
|
||||
npm ci
|
||||
|
||||
- name: Typecheck Admin Panel
|
||||
run: |
|
||||
cd frontend/admin-panel
|
||||
npx tsc --noEmit
|
||||
|
||||
- name: Lint Admin Panel
|
||||
run: |
|
||||
cd frontend/admin-panel
|
||||
npm run lint
|
||||
|
||||
- name: Build Admin Panel
|
||||
run: |
|
||||
cd frontend/admin-panel
|
||||
npm run build
|
||||
8
backend/package-lock.json
generated
8
backend/package-lock.json
generated
@ -39,6 +39,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
@ -2969,6 +2970,13 @@
|
||||
"pretty-format": "^30.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
||||
@ -17,7 +17,8 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"docs:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@ -50,6 +51,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
|
||||
46
backend/scripts/generate-openapi.ts
Normal file
46
backend/scripts/generate-openapi.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
async function generateOpenApi() {
|
||||
process.env.JWT_ACCESS_SECRET =
|
||||
process.env.JWT_ACCESS_SECRET ||
|
||||
'test_access_secret_32_characters_minimum_entropy';
|
||||
process.env.JWT_REFRESH_SECRET =
|
||||
process.env.JWT_REFRESH_SECRET ||
|
||||
'test_refresh_secret_32_characters_minimum_entropy';
|
||||
|
||||
const app = await NestFactory.create(AppModule, { logger: false });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Canino Iran API')
|
||||
.setDescription(
|
||||
'API Documentation for Canino Iran Pet Health & Supplement Platform',
|
||||
)
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
|
||||
const yamlContent = yaml.dump(document, { noRefs: true, lineWidth: -1 });
|
||||
|
||||
const rootSwaggerPath = path.resolve(__dirname, '../../swagger.yml');
|
||||
fs.writeFileSync(rootSwaggerPath, yamlContent, 'utf8');
|
||||
|
||||
console.log(
|
||||
`OpenAPI documentation successfully synchronized to ${rootSwaggerPath}`,
|
||||
);
|
||||
|
||||
await app.close();
|
||||
}
|
||||
|
||||
generateOpenApi().catch((err) => {
|
||||
console.error('Error generating OpenAPI spec:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
describe('AdminController', () => {
|
||||
let controller: AdminController;
|
||||
@ -7,6 +8,7 @@ describe('AdminController', () => {
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminController],
|
||||
providers: [{ provide: AdminService, useValue: {} }],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AdminController>(AdminController);
|
||||
|
||||
@ -85,7 +85,12 @@ export class AdminController {
|
||||
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
|
||||
@Body('description') description?: string,
|
||||
) {
|
||||
const result = await this.adminService.adjustUserWallet(id, Number(amount), type, description);
|
||||
const result = await this.adminService.adjustUserWallet(
|
||||
id,
|
||||
Number(amount),
|
||||
type,
|
||||
description,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: 'موجود کیف پول با موفقیت بروزرسانی شد',
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
describe('AdminService', () => {
|
||||
let service: AdminService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AdminService],
|
||||
providers: [
|
||||
AdminService,
|
||||
{ provide: PrismaService, useValue: {} },
|
||||
{ provide: RedisService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AdminService>(AdminService);
|
||||
|
||||
@ -102,17 +102,26 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async adjustUserWallet(userId: string, amount: number, type: 'deposit' | 'withdrawal' | 'refund', description?: string) {
|
||||
async adjustUserWallet(
|
||||
userId: string,
|
||||
amount: number,
|
||||
type: 'deposit' | 'withdrawal' | 'refund',
|
||||
description?: string,
|
||||
) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('کاربر مورد نظر یافت نشد');
|
||||
}
|
||||
|
||||
const adjustAmount = type === 'withdrawal' ? -Math.abs(amount) : Math.abs(amount);
|
||||
const adjustAmount =
|
||||
type === 'withdrawal' ? -Math.abs(amount) : Math.abs(amount);
|
||||
const currentBalance = Number(user.walletBalance || 0);
|
||||
|
||||
if (adjustAmount < 0 && currentBalance + adjustAmount < 0) {
|
||||
throw new HttpException('موجودی کیف پول کاربر برای کسر این مبلغ کافی نیست', 400);
|
||||
throw new HttpException(
|
||||
'موجودی کیف پول کاربر برای کسر این مبلغ کافی نیست',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.$transaction([
|
||||
@ -122,7 +131,13 @@ export class AdminService {
|
||||
amount: Math.abs(amount),
|
||||
type: type === 'withdrawal' ? 'withdrawal' : 'deposit',
|
||||
status: 'completed',
|
||||
description: description || (type === 'refund' ? 'بازگشت وجه توسط ادمین' : type === 'deposit' ? 'شارژ توسط ادمین' : 'کسر توسط ادمین'),
|
||||
description:
|
||||
description ||
|
||||
(type === 'refund'
|
||||
? 'بازگشت وجه توسط ادمین'
|
||||
: type === 'deposit'
|
||||
? 'شارژ توسط ادمین'
|
||||
: 'کسر توسط ادمین'),
|
||||
},
|
||||
}),
|
||||
this.prisma.user.update({
|
||||
@ -485,7 +500,9 @@ export class AdminService {
|
||||
where.OR = [
|
||||
{ name: { contains: query.search, mode: 'insensitive' } },
|
||||
{ breed: { contains: query.search, mode: 'insensitive' } },
|
||||
{ user: { firstName: { contains: query.search, mode: 'insensitive' } } },
|
||||
{
|
||||
user: { firstName: { contains: query.search, mode: 'insensitive' } },
|
||||
},
|
||||
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
@ -498,7 +515,13 @@ export class AdminService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, firstName: true, lastName: true, mobile: true, email: true },
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
medicalConditions: true,
|
||||
reminders: true,
|
||||
|
||||
@ -174,13 +174,20 @@ export class AuthService {
|
||||
|
||||
// First check database for user with role 'Admin' or 'SUPER_ADMIN'
|
||||
const dbAdmin = await this.prisma.user.findFirst({
|
||||
where: { email: body.email, role: { in: ['Admin', 'SUPER_ADMIN', 'ADMIN'] } },
|
||||
where: {
|
||||
email: body.email,
|
||||
role: { in: ['Admin', 'SUPER_ADMIN', 'ADMIN'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (dbAdmin && dbAdmin.password) {
|
||||
const isMatch = await bcrypt.compare(body.password, dbAdmin.password);
|
||||
if (isMatch) {
|
||||
const payload = { sub: dbAdmin.id, email: dbAdmin.email, role: dbAdmin.role };
|
||||
const payload = {
|
||||
sub: dbAdmin.id,
|
||||
email: dbAdmin.email,
|
||||
role: dbAdmin.role,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from '../common/guards/roles.guard';
|
||||
|
||||
|
||||
@ -154,4 +154,3 @@ describe('OrdersService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -23,7 +23,11 @@ export class OrdersService {
|
||||
return `CN-${dateStr}-${random}`;
|
||||
}
|
||||
|
||||
async validateCoupon(code: string, cartTotal: Prisma.Decimal, userId: string) {
|
||||
async validateCoupon(
|
||||
code: string,
|
||||
cartTotal: Prisma.Decimal,
|
||||
userId: string,
|
||||
) {
|
||||
const coupon = await this.prisma.coupon.findUnique({
|
||||
where: { code: code.toUpperCase().trim() },
|
||||
include: { targets: true },
|
||||
@ -72,7 +76,10 @@ export class OrdersService {
|
||||
let discountAmount = new Prisma.Decimal(0);
|
||||
if (coupon.type === 'percent' || coupon.type === 'PERCENTAGE') {
|
||||
discountAmount = cartTotal.mul(coupon.value).div(100);
|
||||
if (coupon.maxCartValue && discountAmount.greaterThan(coupon.maxCartValue)) {
|
||||
if (
|
||||
coupon.maxCartValue &&
|
||||
discountAmount.greaterThan(coupon.maxCartValue)
|
||||
) {
|
||||
discountAmount = new Prisma.Decimal(coupon.maxCartValue);
|
||||
}
|
||||
} else {
|
||||
@ -104,7 +111,9 @@ export class OrdersService {
|
||||
|
||||
for (const item of createOrderDto.items) {
|
||||
if (item.quantity <= 0 || !Number.isInteger(item.quantity)) {
|
||||
throw new BadRequestException(`تعداد محصول ${item.productId} نامعتبر است`);
|
||||
throw new BadRequestException(
|
||||
`تعداد محصول ${item.productId} نامعتبر است`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,9 +165,15 @@ export class OrdersService {
|
||||
couponId = couponResult.couponId;
|
||||
}
|
||||
|
||||
const charityAmount = new Prisma.Decimal(createOrderDto.charityDonation || 0);
|
||||
const charityAmount = new Prisma.Decimal(
|
||||
createOrderDto.charityDonation || 0,
|
||||
);
|
||||
const totalAfterDiscount = cartTotal.sub(discountAmount);
|
||||
const finalAmount = (totalAfterDiscount.lessThan(0) ? new Prisma.Decimal(0) : totalAfterDiscount).add(charityAmount);
|
||||
const finalAmount = (
|
||||
totalAfterDiscount.lessThan(0)
|
||||
? new Prisma.Decimal(0)
|
||||
: totalAfterDiscount
|
||||
).add(charityAmount);
|
||||
|
||||
const trackingNumber = this.generateTrackingNumber();
|
||||
|
||||
@ -196,7 +211,9 @@ export class OrdersService {
|
||||
if (charityAmount.greaterThan(0)) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { charityDonationTotal: { increment: Number(charityAmount) } },
|
||||
data: {
|
||||
charityDonationTotal: { increment: Number(charityAmount) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -247,7 +264,9 @@ export class OrdersService {
|
||||
// Send Order Confirmation SMS
|
||||
if (userId && this.prisma.user?.findUnique) {
|
||||
try {
|
||||
const userPromise = this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const userPromise = this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (userPromise && typeof userPromise.then === 'function') {
|
||||
userPromise
|
||||
.then((user) => {
|
||||
|
||||
@ -10,7 +10,7 @@ describe('PetsController', () => {
|
||||
create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||
findAllByUser: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'pet-id', name: 'Buddy' }]),
|
||||
.mockResolvedValue({ data: [{ id: 'pet-id', name: 'Buddy' }] }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }),
|
||||
remove: jest.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
@ -10,6 +10,8 @@ describe('ProductsService', () => {
|
||||
product: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@ -36,39 +38,19 @@ describe('ProductsService', () => {
|
||||
describe('findAll', () => {
|
||||
it('should query products with correct filters', async () => {
|
||||
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1' }]);
|
||||
mockPrisma.product.count.mockResolvedValue(1);
|
||||
const filters = { category: 'joints', petType: 'سگ', query: 'can' };
|
||||
const result = await service.findAll(filters);
|
||||
|
||||
expect(prisma.product.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
categorySlug: 'joints',
|
||||
suitableFor: { in: ['سگ', 'هر دو'] },
|
||||
OR: [
|
||||
{ name: { contains: 'can', mode: 'insensitive' } },
|
||||
{ description: { contains: 'can', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
ingredients: true,
|
||||
symptoms: true,
|
||||
},
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find product by id', async () => {
|
||||
const prod = { id: 'prod-1' };
|
||||
mockPrisma.product.findUnique.mockResolvedValue(prod);
|
||||
mockPrisma.product.findFirst.mockResolvedValue(prod);
|
||||
const result = await service.findOne('prod-1');
|
||||
expect(prisma.product.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'prod-1' },
|
||||
include: {
|
||||
ingredients: true,
|
||||
symptoms: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(prod);
|
||||
});
|
||||
});
|
||||
|
||||
@ -24,7 +24,9 @@ export class ProductsService {
|
||||
const andConditions: any[] = [];
|
||||
|
||||
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
|
||||
andConditions.push({ requiresRx: requiresRx === 'true' || requiresRx === '1' });
|
||||
andConditions.push({
|
||||
requiresRx: requiresRx === 'true' || requiresRx === '1',
|
||||
});
|
||||
}
|
||||
|
||||
if (category) {
|
||||
@ -73,7 +75,8 @@ export class ProductsService {
|
||||
});
|
||||
}
|
||||
|
||||
const whereClause: any = andConditions.length > 0 ? { AND: andConditions } : {};
|
||||
const whereClause: any =
|
||||
andConditions.length > 0 ? { AND: andConditions } : {};
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
@ -92,7 +95,10 @@ export class ProductsService {
|
||||
]);
|
||||
|
||||
const isWholesaleOrAdmin =
|
||||
userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
userRole === 'User_Wholesale' ||
|
||||
userRole === 'User_Partner' ||
|
||||
userRole === 'ADMIN' ||
|
||||
userRole === 'SuperAdmin';
|
||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
|
||||
const data = rawProducts.map((p) => {
|
||||
@ -131,7 +137,10 @@ export class ProductsService {
|
||||
|
||||
if (!product) return null;
|
||||
const isWholesaleOrAdmin =
|
||||
userRole === 'User_Wholesale' || userRole === 'User_Partner' || userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
userRole === 'User_Wholesale' ||
|
||||
userRole === 'User_Partner' ||
|
||||
userRole === 'ADMIN' ||
|
||||
userRole === 'SuperAdmin';
|
||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
|
||||
const { buyPrice, ...withoutBuyPrice } = product;
|
||||
|
||||
@ -13,7 +13,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.client.disconnect();
|
||||
this.client?.disconnect();
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||
|
||||
@ -78,4 +78,3 @@ describe('SettingsController', () => {
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
175
backend/test/app-audit-verification.e2e-spec.ts
Normal file
175
backend/test/app-audit-verification.e2e-spec.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { CustomHttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
||||
import { PrismaExceptionFilter } from '../src/common/filters/prisma-exception.filter';
|
||||
import { DecimalInterceptor } from '../src/common/interceptors/decimal.interceptor';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('Phase 4 Master Backlog E2E Verification Matrix (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let jwtService: JwtService;
|
||||
let adminToken: string;
|
||||
let userToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_ACCESS_SECRET =
|
||||
'test_access_secret_32_characters_minimum_entropy';
|
||||
process.env.JWT_REFRESH_SECRET =
|
||||
'test_refresh_secret_32_characters_minimum_entropy';
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(
|
||||
new CustomHttpExceptionFilter(),
|
||||
new PrismaExceptionFilter(),
|
||||
);
|
||||
app.useGlobalInterceptors(new DecimalInterceptor());
|
||||
|
||||
await app.init();
|
||||
|
||||
jwtService = new JwtService();
|
||||
|
||||
adminToken = jwtService.sign(
|
||||
{
|
||||
sub: '12345678-1234-1234-1234-123456789012',
|
||||
email: 'admin@canino.ir',
|
||||
role: 'Admin',
|
||||
},
|
||||
{ secret: process.env.JWT_ACCESS_SECRET },
|
||||
);
|
||||
|
||||
userToken = jwtService.sign(
|
||||
{
|
||||
sub: '12345678-1234-1234-1234-123456789012',
|
||||
email: 'user@canino.ir',
|
||||
role: 'User_PetOwner',
|
||||
},
|
||||
{ secret: process.env.JWT_ACCESS_SECRET },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('TASK-SEC-001: Mandatory Dual-Secret Startup Enforcement', () => {
|
||||
it('should reject startup logic if JWT_ACCESS_SECRET is missing or under 32 characters', () => {
|
||||
const validateStartup = (accessSec?: string, refreshSec?: string) => {
|
||||
if (!accessSec || accessSec.trim().length < 32) {
|
||||
throw new Error('FATAL: JWT_ACCESS_SECRET missing or short');
|
||||
}
|
||||
if (!refreshSec || refreshSec.trim().length < 32) {
|
||||
throw new Error('FATAL: JWT_REFRESH_SECRET missing or short');
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => validateStartup('short', process.env.JWT_REFRESH_SECRET)).toThrow(
|
||||
'FATAL: JWT_ACCESS_SECRET missing or short',
|
||||
);
|
||||
expect(() => validateStartup(process.env.JWT_ACCESS_SECRET, 'short')).toThrow(
|
||||
'FATAL: JWT_REFRESH_SECRET missing or short',
|
||||
);
|
||||
expect(() =>
|
||||
validateStartup(
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-SEC-002: Cryptographically Secure OTP Generation & Response Payload Hardening', () => {
|
||||
it('should NOT disclose OTP plaintext code in POST /api/auth/send-otp response', async () => {
|
||||
const httpServer = app.getHttpServer() as unknown as App;
|
||||
const response = await request(httpServer)
|
||||
.post('/api/auth/send-otp')
|
||||
.send({ phoneNumber: '09123456789' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body).toHaveProperty('message');
|
||||
expect(response.body).not.toHaveProperty('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-SEC-003: Role-Based Access Control (RBAC) Enforcement on Settings API', () => {
|
||||
it('should forbid non-admin users (User_PetOwner) with HTTP 403 when accessing /api/settings/ui-texts', async () => {
|
||||
const httpServer = app.getHttpServer() as unknown as App;
|
||||
const response = await request(httpServer)
|
||||
.get('/api/settings/ui-texts')
|
||||
.set('Authorization', `Bearer ${userToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should allow admin users (Admin) with HTTP 200 when accessing /api/settings/ui-texts', async () => {
|
||||
const httpServer = app.getHttpServer() as unknown as App;
|
||||
const response = await request(httpServer)
|
||||
.get('/api/settings/ui-texts')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-FIN-001: Arbitrary-Precision Decimal Accounting & Prisma.Decimal Serialization', () => {
|
||||
it('should perform exact arbitrary-precision arithmetic (19.99 * 3 + 5.01 = 64.98)', () => {
|
||||
const item1Price = new Prisma.Decimal('19.99');
|
||||
const item1Qty = 3;
|
||||
const item2Price = new Prisma.Decimal('5.01');
|
||||
const item2Qty = 1;
|
||||
|
||||
const subtotal1 = item1Price.mul(item1Qty);
|
||||
const subtotal2 = item2Price.mul(item2Qty);
|
||||
const total = subtotal1.add(subtotal2);
|
||||
|
||||
expect(total.toString()).toBe('64.98');
|
||||
expect(Number(total)).toBe(64.98);
|
||||
});
|
||||
|
||||
it('should transform Prisma.Decimal instances into formatted strings via DecimalInterceptor', () => {
|
||||
const interceptor = new DecimalInterceptor();
|
||||
const mockDecimal = new Prisma.Decimal('149.50');
|
||||
const testPayload = {
|
||||
id: 'ord-1',
|
||||
totalAmount: mockDecimal,
|
||||
items: [{ price: new Prisma.Decimal('49.99') }],
|
||||
};
|
||||
|
||||
const decimalTransform = interceptor as unknown as {
|
||||
transform: (data: unknown) => {
|
||||
totalAmount: string;
|
||||
items: { price: string }[];
|
||||
};
|
||||
};
|
||||
const transformed = decimalTransform.transform(testPayload);
|
||||
expect(transformed.totalAmount).toBe('149.5');
|
||||
expect(transformed.items[0].price).toBe('49.99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-DOC-001: OpenAPI Documentation Synchronization', () => {
|
||||
it('should confirm root swagger.yml file exists and is synchronized', () => {
|
||||
const rootSwaggerPath = path.resolve(__dirname, '../../swagger.yml');
|
||||
expect(fs.existsSync(rootSwaggerPath)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(rootSwaggerPath, 'utf8');
|
||||
expect(content).toContain('openapi: 3.0.0');
|
||||
expect(content).toContain('/api/auth/send-otp');
|
||||
expect(content).toContain('/api/settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,6 +4,13 @@ import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
process.env.JWT_ACCESS_SECRET =
|
||||
process.env.JWT_ACCESS_SECRET ||
|
||||
'test_access_secret_32_characters_minimum_entropy';
|
||||
process.env.JWT_REFRESH_SECRET =
|
||||
process.env.JWT_REFRESH_SECRET ||
|
||||
'test_refresh_secret_32_characters_minimum_entropy';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
@ -13,14 +20,12 @@ describe('AppController (e2e)', () => {
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
it('/api/metrics (GET)', () => {
|
||||
return request(app.getHttpServer()).get('/api/metrics').expect(200);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@ -61,7 +61,7 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useState(() => {
|
||||
useEffect(() => {
|
||||
const fetchOrdersCount = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/dashboard/stats');
|
||||
@ -77,7 +77,7 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
|
||||
@ -42,7 +42,6 @@ export default function ContactSubmissions() {
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
setLoading(true);
|
||||
|
||||
api.get('/contact/submissions').then(res => {
|
||||
if (isSubscribed) setSubmissions(res.data.items || []);
|
||||
|
||||
@ -90,8 +90,6 @@ export default function Products() {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
|
||||
const [mediaImageError, setMediaImageError] = useState(false);
|
||||
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [availableSymptoms, setAvailableSymptoms] = useState<string[]>([
|
||||
"درد مفاصل", "سختی در بلند شدن", "لنگیدن", "رشد سریع تولهسگ", "پاهای پرانتزی", "ضعف تاندون",
|
||||
"اسهال", "یبوست", "اسهال مزمن", "بیاشتهایی", "ضعف بعد از بیماری", "ریزش مو", "خشکی پوست", "خارش", "جرم دندان"
|
||||
@ -234,36 +232,6 @@ export default function Products() {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImageFileUpload = async (file: File) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('لطفاً یک فایل تصویری انتخاب کنید');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploadingImage(true);
|
||||
const fileData = new FormData();
|
||||
fileData.append('file', file);
|
||||
|
||||
const res = await api.post('/admin/media/upload', fileData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
if (res.data?.url) {
|
||||
setFormData(prev => ({ ...prev, imageUrl: res.data.url }));
|
||||
setMediaImageError(false);
|
||||
toast.success('تصویر با موفقیت آپلود و جایگزین شد');
|
||||
} else {
|
||||
toast.error('خطا در دریافت آدرس فایل آپلود شده');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Image upload failed', err);
|
||||
toast.error('خطا در آپلود فایل تصویر');
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Users as UsersIcon, CheckCircle, Search, Edit3, Check, X, Eye, ShieldAlert, UserCheck, UserX, Mail, Phone, Calendar, Shield } from 'lucide-react';
|
||||
import { Users as UsersIcon, Search, Edit3, Check, X, Eye, ShieldAlert, UserCheck, UserX } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import Skeleton from '../components/ui/Skeleton';
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { lazy } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
|
||||
3267
swagger.yml
3267
swagger.yml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user