feat: complete testing, security, dynamic content, and devops overhaul

This commit is contained in:
پارسا آقایی 2026-05-27 02:00:14 +03:30
parent 467532713c
commit 40ff169dc2
52 changed files with 3918 additions and 104 deletions

View File

@ -7,8 +7,8 @@ COPY . .
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM nginx:alpine FROM nginxinc/nginx-unprivileged:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -28,11 +28,13 @@ RUN npm ci --only=production
# Copy built artifacts from the builder stage # Copy built artifacts from the builder stage
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# If we have prisma later, we need to copy generated client: # Copy generated prisma client:
# COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
EXPOSE 3000 EXPOSE 3000
USER node
# Start the application # Start the application
CMD ["node", "dist/main"] CMD ["node", "dist/main"]

View File

@ -15,10 +15,12 @@
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.4", "@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.2.0",
"ioredis": "^5.11.0", "ioredis": "^5.11.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@ -2537,6 +2539,17 @@
} }
} }
}, },
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -6272,6 +6285,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",

View File

@ -26,10 +26,12 @@
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.4.4", "@nestjs/swagger": "^11.4.4",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"helmet": "^8.2.0",
"ioredis": "^5.11.0", "ioredis": "^5.11.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",

View File

@ -7,6 +7,25 @@ import * as vm from 'vm';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function main() {
console.log('Wiping existing database records...');
await prisma.reminderCompletion.deleteMany();
await prisma.reminder.deleteMany();
await prisma.healthLog.deleteMany();
await prisma.petMedicalCondition.deleteMany();
await prisma.pet.deleteMany();
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.walletTransaction.deleteMany();
await prisma.userAddress.deleteMany();
await prisma.user.deleteMany();
await prisma.productIngredient.deleteMany();
await prisma.productSymptom.deleteMany();
await prisma.product.deleteMany();
await prisma.coupon.deleteMany();
await prisma.uiText.deleteMany();
await prisma.scientificTerm.deleteMany();
console.log('Database successfully wiped.');
console.log('Seeding database with products...'); console.log('Seeding database with products...');
const productsFilePath = path.join(__dirname, '..', '..', 'src', 'data', 'products.ts'); const productsFilePath = path.join(__dirname, '..', '..', 'src', 'data', 'products.ts');

View File

@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module'; import { ProductsModule } from './products/products.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@ -7,10 +7,41 @@ import { AuthModule } from './auth/auth.module';
import { PetsModule } from './pets/pets.module'; import { PetsModule } from './pets/pets.module';
import { OrdersModule } from './orders/orders.module'; import { OrdersModule } from './orders/orders.module';
import { SettingsModule } from './settings/settings.module'; import { SettingsModule } from './settings/settings.module';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { MetricsController } from './common/metrics.controller';
@Module({ @Module({
imports: [PrismaModule, RedisModule, ProductsModule, UsersModule, AuthModule, PetsModule, OrdersModule, SettingsModule], imports: [
controllers: [], PrismaModule,
providers: [], RedisModule,
ProductsModule,
UsersModule,
AuthModule,
PetsModule,
OrdersModule,
SettingsModule,
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 100,
}]),
],
controllers: [MetricsController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
}) })
export class AppModule {} export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply((req: any, res: any, next: () => void) => {
MetricsController.incrementRequestCount();
next();
})
.exclude('metrics')
.forRoutes('*');
}
}

View File

@ -0,0 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
describe('AuthController', () => {
let controller: AuthController;
let service: AuthService;
const mockAuthService = {
sendOtp: jest.fn().mockResolvedValue({ success: true, message: 'کد تایید ارسال شد' }),
verifyOtp: jest.fn().mockResolvedValue({ success: true, data: { accessToken: 'token' } }),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
],
}).compile();
controller = module.get<AuthController>(AuthController);
service = module.get<AuthService>(AuthService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should call sendOtp on service', async () => {
const dto = { phoneNumber: '09123456789' };
const result = await controller.sendOtp(dto);
expect(service.sendOtp).toHaveBeenCalledWith(dto);
expect(result.success).toBe(true);
});
it('should call verifyOtp on service', async () => {
const dto = { phoneNumber: '09123456789', code: '12345' };
const result = await controller.verifyOtp(dto);
expect(service.verifyOtp).toHaveBeenCalledWith(dto);
expect(result.data.accessToken).toBe('token');
});
});

View File

@ -2,25 +2,92 @@ import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { SendOtpDto } from './dto/send-otp.dto'; import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiBadRequestResponse } from '@nestjs/swagger';
@ApiTags('Auth') @ApiTags('Auth - احراز هویت')
@Controller('auth') @Controller('auth')
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'خطای داخلی سرور',
schema: {
example: {
success: false,
message: 'خطای ناشناخته در سرور رخ داده است',
code: 'SERVER_ERROR',
details: {}
}
}
})
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Post('send-otp') @Post('send-otp')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ارسال کد تایید پیامکی' }) @ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' })
@ApiResponse({ status: 200, description: 'کد با موفقیت ارسال شد' }) @ApiOkResponse({
description: 'کد با موفقیت ارسال شد (کد تایید در پاسخ بازگردانده می‌شود)',
schema: {
example: {
success: true,
message: 'کد تایید ارسال شد',
code: '12345'
}
}
})
@ApiBadRequestResponse({
description: 'فرمت شماره تلفن همراه نامعتبر است',
schema: {
example: {
success: false,
message: 'شماره موبایل نامعتبر است',
code: 'BAD_REQUEST',
details: {
message: ['شماره موبایل نامعتبر است']
}
}
}
})
sendOtp(@Body() sendOtpDto: SendOtpDto) { sendOtp(@Body() sendOtpDto: SendOtpDto) {
return this.authService.sendOtp(sendOtpDto); return this.authService.sendOtp(sendOtpDto);
} }
@Post('verify-otp') @Post('verify-otp')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'تایید کد پیامکی و ورود/ثبت‌نام' }) @ApiOperation({ summary: 'تایید کد پیامکی و ورود/ثبت‌نام کاربر' })
@ApiResponse({ status: 200, description: 'ورود موفق به همراه توکن' }) @ApiOkResponse({
description: 'ورود موفق به همراه توکن دسترسی JWT و اطلاعات کاربر',
schema: {
example: {
success: true,
data: {
user: {
id: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
firstName: 'کاربر',
lastName: 'جدید',
email: '09123456789@temp.local',
mobile: '09123456789',
role: 'User_PetOwner',
walletBalance: '0.00',
charityDonationTotal: '0.00',
createdAt: '2026-05-26T15:20:00.000Z',
updatedAt: '2026-05-26T15:20:00.000Z'
},
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
}
}
}
})
@ApiBadRequestResponse({
description: 'کد تایید اشتباه یا منقضی شده است',
schema: {
example: {
success: false,
message: 'کد تایید اشتباه است',
code: 'OTP_INVALID',
details: {}
}
}
})
verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) { verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
return this.authService.verifyOtp(verifyOtpDto); return this.authService.verifyOtp(verifyOtpDto);
} }

View File

@ -0,0 +1,109 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { RedisService } from '../redis/redis.service';
import { BadRequestException } from '@nestjs/common';
describe('AuthService', () => {
let service: AuthService;
let prisma: PrismaService;
let jwt: JwtService;
let redis: RedisService;
const mockPrisma = {
user: {
findUnique: jest.fn(),
create: jest.fn(),
},
};
const mockJwt = {
sign: jest.fn().mockReturnValue('mock-jwt-token'),
};
const mockRedis = {
set: jest.fn(),
get: jest.fn(),
del: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwt },
{ provide: RedisService, useValue: mockRedis },
],
}).compile();
service = module.get<AuthService>(AuthService);
prisma = module.get<PrismaService>(PrismaService);
jwt = module.get<JwtService>(JwtService);
redis = module.get<RedisService>(RedisService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('sendOtp', () => {
it('should generate a 5 digit OTP and save it in Redis', async () => {
const result = await service.sendOtp({ phoneNumber: '09123456789' });
expect(result.success).toBe(true);
expect(result.code).toHaveLength(5);
expect(redis.set).toHaveBeenCalledWith(
'otp:09123456789',
result.code,
120,
);
});
});
describe('verifyOtp', () => {
it('should throw BadRequestException if OTP is expired/not found', async () => {
mockRedis.get.mockResolvedValue(null);
await expect(
service.verifyOtp({ phoneNumber: '09123456789', code: '12345' }),
).rejects.toThrow(BadRequestException);
});
it('should throw BadRequestException if OTP is incorrect', async () => {
mockRedis.get.mockResolvedValue('54321');
await expect(
service.verifyOtp({ phoneNumber: '09123456789', code: '12345' }),
).rejects.toThrow(BadRequestException);
});
it('should delete OTP, find or create user and sign JWT', async () => {
mockRedis.get.mockResolvedValue('12345');
const mockUser = { id: 'user-id', mobile: '09123456789' };
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const result = await service.verifyOtp({ phoneNumber: '09123456789', code: '12345' });
expect(redis.del).toHaveBeenCalledWith('otp:09123456789');
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { mobile: '09123456789' } });
expect(jwt.sign).toHaveBeenCalledWith({ sub: 'user-id', phoneNumber: '09123456789' });
expect(result.success).toBe(true);
expect(result.data.accessToken).toBe('mock-jwt-token');
expect(result.data.user).toEqual(mockUser);
});
it('should create new user if user does not exist', async () => {
mockRedis.get.mockResolvedValue('12345');
mockPrisma.user.findUnique.mockResolvedValue(null);
const newUser = { id: 'new-user-id', mobile: '09123456789' };
mockPrisma.user.create.mockResolvedValue(newUser);
const result = await service.verifyOtp({ phoneNumber: '09123456789', code: '12345' });
expect(prisma.user.create).toHaveBeenCalled();
expect(result.data.user).toEqual(newUser);
});
});
});

View File

@ -0,0 +1,67 @@
import { Controller, Get, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import { PrismaService } from '../../src/prisma/prisma.service';
import { Response } from 'express';
@ApiExcludeController()
@Controller('metrics')
export class MetricsController {
private static requestCount = 0;
constructor(private readonly prisma: PrismaService) {}
public static incrementRequestCount() {
this.requestCount++;
}
@Get()
async getMetrics(@Res() res: Response) {
const memory = process.memoryUsage();
const cpu = process.cpuUsage();
let dbStatus = 1;
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch (e) {
dbStatus = 0;
}
const uptime = process.uptime();
const responseText = `# HELP node_memory_rss_bytes Resident set size in bytes.
# TYPE node_memory_rss_bytes gauge
node_memory_rss_bytes ${memory.rss}
# HELP node_memory_heap_used_bytes Heap used in bytes.
# TYPE node_memory_heap_used_bytes gauge
node_memory_heap_used_bytes ${memory.heapUsed}
# HELP node_memory_heap_total_bytes Heap total in bytes.
# TYPE node_memory_heap_total_bytes gauge
node_memory_heap_total_bytes ${memory.heapTotal}
# HELP node_cpu_user_time_microseconds CPU user time in microseconds.
# TYPE node_cpu_user_time_microseconds counter
node_cpu_user_time_microseconds ${cpu.user}
# HELP node_cpu_system_time_microseconds CPU system time in microseconds.
# TYPE node_cpu_system_time_microseconds counter
node_cpu_system_time_microseconds ${cpu.system}
# HELP node_app_uptime_seconds Application uptime in seconds.
# TYPE node_app_uptime_seconds gauge
node_app_uptime_seconds ${uptime}
# HELP canina_db_connected Database connection status (1 = connected, 0 = disconnected).
# TYPE canina_db_connected gauge
canina_db_connected ${dbStatus}
# HELP canina_http_requests_total Total HTTP requests processed.
# TYPE canina_http_requests_total counter
canina_http_requests_total ${MetricsController.requestCount}
`;
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
res.end(responseText);
}
}

View File

@ -3,14 +3,27 @@ import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import helmet from 'helmet';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.use(helmet({
contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets
}));
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.enableCors();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.enableCors({
origin: true,
credentials: true,
});
app.useGlobalPipes(new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}));
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
const config = new DocumentBuilder() const config = new DocumentBuilder()

View File

@ -0,0 +1,56 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
describe('OrdersController', () => {
let controller: OrdersController;
let service: OrdersService;
const mockOrdersService = {
create: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }),
findAllByUser: jest.fn().mockResolvedValue([{ id: 'order-id', totalAmount: 1000 }]),
findOne: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OrdersController],
providers: [
{ provide: OrdersService, useValue: mockOrdersService },
],
}).compile();
controller = module.get<OrdersController>(OrdersController);
service = module.get<OrdersService>(OrdersService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should create order', async () => {
const req = { user: { id: 'user-id' } };
const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
const result = await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith('user-id', dto);
expect(result.id).toBe('order-id');
});
it('should list orders of user', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.findAll(req);
expect(service.findAllByUser).toHaveBeenCalledWith('user-id');
expect(result).toHaveLength(1);
});
it('should find one order', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.findOne(req, 'order-id');
expect(service.findOne).toHaveBeenCalledWith('order-id', 'user-id');
expect(result.id).toBe('order-id');
});
});

View File

@ -1,30 +1,120 @@
import { Controller, Get, Post, Body, Param, UseGuards, Req } from '@nestjs/common'; import { Controller, Get, Post, Body, Param, UseGuards, Req, HttpStatus } from '@nestjs/common';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
@ApiTags('Orders') @ApiTags('Orders - مدیریت سفارش‌ها')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('orders') @Controller('orders')
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است',
schema: {
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
details: {}
}
}
})
export class OrdersController { export class OrdersController {
constructor(private readonly ordersService: OrdersService) {} constructor(private readonly ordersService: OrdersService) {}
@Post() @Post()
@ApiOperation({ summary: 'ثبت سفارش جدید' }) @ApiOperation({ summary: 'ثبت سفارش جدید' })
@ApiCreatedResponse({
description: 'سفارش با موفقیت ثبت شد',
schema: {
example: {
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
couponId: null,
totalAmount: '3500000.00',
charityDonation: '10000.00',
status: 'processing',
trackingNumber: null,
createdAt: '2026-05-26T18:10:00.000Z'
}
}
})
@ApiBadRequestResponse({
description: 'اعتبارسنجی اقلام سبد خرید با خطا مواجه شد',
schema: {
example: {
success: false,
message: 'سبد خرید نمی‌تواند خالی باشد',
code: 'BAD_REQUEST',
details: {}
}
}
})
create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) { create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
return this.ordersService.create(req.user.id, createOrderDto); return this.ordersService.create(req.user.id, createOrderDto);
} }
@Get() @Get()
@ApiOperation({ summary: 'لیست سفارش‌های کاربر' }) @ApiOperation({ summary: 'لیست سفارش‌های کاربر فعلی' })
@ApiOkResponse({
description: 'آرایه‌ای از سفارش‌های ثبت شده کاربر',
schema: {
example: [
{
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
totalAmount: '3500000.00',
charityDonation: '10000.00',
status: 'processing',
createdAt: '2026-05-26T18:10:00.000Z'
}
]
}
})
findAll(@Req() req: any) { findAll(@Req() req: any) {
return this.ordersService.findAllByUser(req.user.id); return this.ordersService.findAllByUser(req.user.id);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'جزئیات یک سفارش' }) @ApiOperation({ summary: 'دریافت جزئیات یک سفارش خاص' })
@ApiOkResponse({
description: 'اطلاعات کامل سفارش مشخص شده به همراه اقلام سفارش',
schema: {
example: {
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
totalAmount: '3500000.00',
charityDonation: '10000.00',
status: 'processing',
trackingNumber: 'TRK-987654321',
createdAt: '2026-05-26T18:10:00.000Z',
orderItems: [
{
id: 'item-1',
quantity: 2,
product: {
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
name: 'Canhydrox GAG (کنهیدروکس)',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
imageUrl: 'https://example.com/canhydrox.png'
}
}
]
}
}
})
@ApiNotFoundResponse({
description: 'سفارش یافت نشد یا متعلق به کاربر فعلی نیست',
schema: {
example: {
success: false,
message: 'Order not found',
code: 'NOT_FOUND',
details: {}
}
}
})
findOne(@Req() req: any, @Param('id') id: string) { findOne(@Req() req: any, @Param('id') id: string) {
return this.ordersService.findOne(id, req.user.id); return this.ordersService.findOne(id, req.user.id);
} }

View File

@ -0,0 +1,103 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from './orders.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotFoundException, BadRequestException } from '@nestjs/common';
describe('OrdersService', () => {
let service: OrdersService;
let prisma: PrismaService;
const mockPrisma = {
product: {
findUnique: jest.fn(),
},
order: {
create: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<OrdersService>(OrdersService);
prisma = module.get<PrismaService>(PrismaService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('create', () => {
it('should throw NotFoundException if product does not exist', async () => {
mockPrisma.product.findUnique.mockResolvedValue(null);
const dto = { items: [{ productId: 'invalid-prod', quantity: 2 }] };
await expect(service.create('user-id', dto)).rejects.toThrow(NotFoundException);
});
it('should throw BadRequestException if items are empty', async () => {
const dto = { items: [] };
await expect(service.create('user-id', dto)).rejects.toThrow(BadRequestException);
});
it('should successfully create order and sum amounts', async () => {
const prod = { id: 'prod-1', priceValue: 1000 };
mockPrisma.product.findUnique.mockResolvedValue(prod);
mockPrisma.order.create.mockResolvedValue({ id: 'order-1', totalAmount: 2000 });
const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
const result = await service.create('user-id', dto);
expect(prisma.product.findUnique).toHaveBeenCalledWith({ where: { id: 'prod-1' } });
expect(prisma.order.create).toHaveBeenCalledWith({
data: {
userId: 'user-id',
totalAmount: 2000,
status: 'processing',
orderItems: {
create: [{ productId: 'prod-1', quantity: 2 }],
},
},
include: {
orderItems: {
include: { product: true },
},
},
});
expect(result.id).toBe('order-1');
});
});
describe('findAllByUser', () => {
it('should find all orders of a user', async () => {
mockPrisma.order.findMany.mockResolvedValue([{ id: 'order-1' }]);
const result = await service.findAllByUser('user-id');
expect(prisma.order.findMany).toHaveBeenCalled();
expect(result).toHaveLength(1);
});
});
describe('findOne', () => {
it('should throw NotFoundException if order does not exist', async () => {
mockPrisma.order.findFirst.mockResolvedValue(null);
await expect(service.findOne('order-id', 'user-id')).rejects.toThrow(NotFoundException);
});
it('should return order if found', async () => {
const order = { id: 'order-1', userId: 'user-id' };
mockPrisma.order.findFirst.mockResolvedValue(order);
const result = await service.findOne('order-1', 'user-id');
expect(result).toEqual(order);
});
});
});

View File

@ -0,0 +1,73 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PetsController } from './pets.controller';
import { PetsService } from './pets.service';
describe('PetsController', () => {
let controller: PetsController;
let service: PetsService;
const mockPetsService = {
create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
findAllByUser: jest.fn().mockResolvedValue([{ 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 }),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PetsController],
providers: [
{ provide: PetsService, useValue: mockPetsService },
],
}).compile();
controller = module.get<PetsController>(PetsController);
service = module.get<PetsService>(PetsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should create pet', async () => {
const req = { user: { id: 'user-id' } };
const dto = { name: 'Buddy', type: 'Dog' };
const result = await controller.create(req, dto);
expect(service.create).toHaveBeenCalledWith('user-id', dto);
expect(result.id).toBe('pet-id');
});
it('should findAll pets', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.findAll(req);
expect(service.findAllByUser).toHaveBeenCalledWith('user-id');
expect(result).toHaveLength(1);
});
it('should findOne pet', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.findOne(req, 'pet-id');
expect(service.findOne).toHaveBeenCalledWith('pet-id', 'user-id');
expect(result.id).toBe('pet-id');
});
it('should update pet', async () => {
const req = { user: { id: 'user-id' } };
const dto = { name: 'Buddy New' };
const result = await controller.update(req, 'pet-id', dto);
expect(service.update).toHaveBeenCalledWith('pet-id', 'user-id', dto);
expect(result.name).toBe('Buddy New');
});
it('should remove pet', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.remove(req, 'pet-id');
expect(service.remove).toHaveBeenCalledWith('pet-id', 'user-id');
expect(result.success).toBe(true);
});
});

View File

@ -1,43 +1,178 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req } from '@nestjs/common'; import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req, HttpStatus } from '@nestjs/common';
import { PetsService } from './pets.service'; import { PetsService } from './pets.service';
import { CreatePetDto } from './dto/create-pet.dto'; import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto'; import { UpdatePetDto } from './dto/update-pet.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
@ApiTags('Pets') @ApiTags('Pets - مدیریت حیوانات خانگی')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pets') @Controller('pets')
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است',
schema: {
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
details: {}
}
}
})
export class PetsController { export class PetsController {
constructor(private readonly petsService: PetsService) {} constructor(private readonly petsService: PetsService) {}
@Post() @Post()
@ApiOperation({ summary: 'ثبت حیوان خانگی جدید' }) @ApiOperation({ summary: 'ثبت حیوان خانگی جدید' })
@ApiCreatedResponse({
description: 'حیوان خانگی جدید با موفقیت ثبت شد',
schema: {
example: {
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
name: 'بادی',
type: 'سگ',
breed: 'ژرمن شپرد',
age: 3,
weight: '25.50',
activityLevel: 'متوسط',
imageUrl: null,
createdAt: '2026-05-26T18:10:00.000Z'
}
}
})
@ApiBadRequestResponse({
description: 'خطا در صحت‌سنجی فیلدهای ورودی',
schema: {
example: {
success: false,
message: 'نوع حیوان خانگی اجباری است',
code: 'BAD_REQUEST',
details: {}
}
}
})
create(@Req() req: any, @Body() createPetDto: CreatePetDto) { create(@Req() req: any, @Body() createPetDto: CreatePetDto) {
return this.petsService.create(req.user.id, createPetDto); return this.petsService.create(req.user.id, createPetDto);
} }
@Get() @Get()
@ApiOperation({ summary: 'لیست حیوانات خانگی کاربر' }) @ApiOperation({ summary: 'لیست حیوانات خانگی کاربر فعلی' })
@ApiOkResponse({
description: 'آرایه‌ای از حیوانات خانگی ثبت شده کاربر',
schema: {
example: [
{
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
name: 'بادی',
type: 'سگ',
breed: 'ژرمن شپرد',
age: 3,
weight: '25.50',
activityLevel: 'متوسط',
imageUrl: null,
createdAt: '2026-05-26T18:10:00.000Z'
}
]
}
})
findAll(@Req() req: any) { findAll(@Req() req: any) {
return this.petsService.findAllByUser(req.user.id); return this.petsService.findAllByUser(req.user.id);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'دریافت جزئیات یک حیوان خانگی' }) @ApiOperation({ summary: 'دریافت جزئیات کامل یک حیوان خانگی' })
@ApiOkResponse({
description: 'اطلاعات کامل حیوان خانگی مشخص شده با ID',
schema: {
example: {
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
name: 'بادی',
type: 'سگ',
breed: 'ژرمن شپرد',
age: 3,
weight: '25.50',
activityLevel: 'متوسط',
imageUrl: null,
medicalConditions: [],
reminders: [],
healthLogs: [],
createdAt: '2026-05-26T18:10:00.000Z'
}
}
})
@ApiNotFoundResponse({
description: 'حیوان خانگی پیدا نشد یا متعلق به کاربر جاری نیست',
schema: {
example: {
success: false,
message: 'Pet not found or unauthorized',
code: 'NOT_FOUND',
details: {}
}
}
})
findOne(@Req() req: any, @Param('id') id: string) { findOne(@Req() req: any, @Param('id') id: string) {
return this.petsService.findOne(id, req.user.id); return this.petsService.findOne(id, req.user.id);
} }
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'بروزرسانی اطلاعات حیوان خانگی' }) @ApiOperation({ summary: 'بروزرسانی اطلاعات حیوان خانگی' })
@ApiOkResponse({
description: 'اطلاعات حیوان خانگی با موفقیت به‌روزرسانی شد',
schema: {
example: {
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
name: 'بادی قهرمان',
type: 'سگ',
breed: 'ژرمن شپرد',
age: 4,
weight: '26.00',
activityLevel: 'زیاد'
}
}
})
@ApiNotFoundResponse({
description: 'حیوان خانگی یافت نشد',
schema: {
example: {
success: false,
message: 'Pet not found',
code: 'NOT_FOUND',
details: {}
}
}
})
update(@Req() req: any, @Param('id') id: string, @Body() updatePetDto: UpdatePetDto) { update(@Req() req: any, @Param('id') id: string, @Body() updatePetDto: UpdatePetDto) {
return this.petsService.update(id, req.user.id, updatePetDto); return this.petsService.update(id, req.user.id, updatePetDto);
} }
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'حذف حیوان خانگی' }) @ApiOperation({ summary: 'حذف حیوان خانگی' })
@ApiOkResponse({
description: 'حیوان خانگی با موفقیت حذف شد',
schema: {
example: {
success: true,
message: 'حیوان خانگی با موفقیت حذف شد'
}
}
})
@ApiNotFoundResponse({
description: 'حیوان خانگی یافت نشد',
schema: {
example: {
success: false,
message: 'Pet not found',
code: 'NOT_FOUND',
details: {}
}
}
})
remove(@Req() req: any, @Param('id') id: string) { remove(@Req() req: any, @Param('id') id: string) {
return this.petsService.remove(id, req.user.id); return this.petsService.remove(id, req.user.id);
} }

View File

@ -0,0 +1,93 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PetsService } from './pets.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotFoundException } from '@nestjs/common';
describe('PetsService', () => {
let service: PetsService;
let prisma: PrismaService;
const mockPrisma = {
pet: {
create: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PetsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<PetsService>(PetsService);
prisma = module.get<PrismaService>(PrismaService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should create pet', async () => {
const dto = { name: 'Buddy', type: 'Dog', weight: 12.5 };
mockPrisma.pet.create.mockResolvedValue({ id: 'pet-id', ...dto });
const result = await service.create('user-id', dto);
expect(prisma.pet.create).toHaveBeenCalled();
expect(result.name).toBe('Buddy');
});
it('should find all pets by user', async () => {
mockPrisma.pet.findMany.mockResolvedValue([{ id: 'pet-1' }]);
const result = await service.findAllByUser('user-id');
expect(prisma.pet.findMany).toHaveBeenCalledWith({
where: { userId: 'user-id' },
orderBy: { createdAt: 'desc' },
});
expect(result).toHaveLength(1);
});
describe('findOne', () => {
it('should throw NotFoundException if pet not found', async () => {
mockPrisma.pet.findFirst.mockResolvedValue(null);
await expect(service.findOne('pet-id', 'user-id')).rejects.toThrow(NotFoundException);
});
it('should return pet if found', async () => {
const pet = { id: 'pet-id', userId: 'user-id' };
mockPrisma.pet.findFirst.mockResolvedValue(pet);
const result = await service.findOne('pet-id', 'user-id');
expect(result).toEqual(pet);
});
});
it('should update pet', async () => {
const pet = { id: 'pet-id', userId: 'user-id' };
mockPrisma.pet.findFirst.mockResolvedValue(pet);
const dto = { name: 'Buddy New', type: 'Dog' };
mockPrisma.pet.update.mockResolvedValue({ ...pet, ...dto });
const result = await service.update('pet-id', 'user-id', dto);
expect(prisma.pet.update).toHaveBeenCalled();
expect(result.name).toBe('Buddy New');
});
it('should remove pet', async () => {
const pet = { id: 'pet-id', userId: 'user-id' };
mockPrisma.pet.findFirst.mockResolvedValue(pet);
mockPrisma.pet.delete.mockResolvedValue(pet);
const result = await service.remove('pet-id', 'user-id');
expect(prisma.pet.delete).toHaveBeenCalled();
expect(result.id).toBe('pet-id');
});
});

View File

@ -0,0 +1,56 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { NotFoundException } from '@nestjs/common';
describe('ProductsController', () => {
let controller: ProductsController;
let service: ProductsService;
const mockProductsService = {
findAll: jest.fn().mockResolvedValue([{ id: 'prod-id', name: 'Product 1' }]),
findOne: jest.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ProductsController],
providers: [
{ provide: ProductsService, useValue: mockProductsService },
],
}).compile();
controller = module.get<ProductsController>(ProductsController);
service = module.get<ProductsService>(ProductsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should list products', async () => {
const query = { category: 'joints' };
const result = await controller.findAll(query);
expect(service.findAll).toHaveBeenCalledWith(query);
expect(result).toHaveLength(1);
});
describe('findOne', () => {
it('should throw NotFoundException if product not found', async () => {
mockProductsService.findOne.mockResolvedValue(null);
await expect(controller.findOne('invalid-id')).rejects.toThrow(NotFoundException);
});
it('should return product details if found', async () => {
const prod = { id: 'prod-id', name: 'Product 1' };
mockProductsService.findOne.mockResolvedValue(prod);
const result = await controller.findOne('prod-id');
expect(service.findOne).toHaveBeenCalledWith('prod-id');
expect(result).toEqual(prod);
});
});
});

View File

@ -1,23 +1,100 @@
import { Controller, Get, Query, Param, NotFoundException } from '@nestjs/common'; import { Controller, Get, Query, Param, NotFoundException, HttpStatus } from '@nestjs/common';
import { ProductsService } from './products.service'; import { ProductsService } from './products.service';
import { GetProductsDto } from './dto/get-products.dto'; import { GetProductsDto } from './dto/get-products.dto';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
@ApiTags('Products') @ApiTags('Products - مدیریت محصولات دارویی')
@Controller('products') @Controller('products')
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'خطای داخلی سرور',
schema: {
example: {
success: false,
message: 'خطای داخلی سرور',
code: 'SERVER_ERROR',
details: {}
}
}
})
export class ProductsController { export class ProductsController {
constructor(private readonly productsService: ProductsService) {} constructor(private readonly productsService: ProductsService) {}
@Get() @Get()
@ApiOperation({ summary: 'List Products' }) @ApiOperation({ summary: 'لیست و فیلتر محصولات' })
@ApiResponse({ status: 200, description: 'List of products' }) @ApiOkResponse({
description: 'لیست محصولات متناسب با فیلترها (دسته، پت و جستجو)',
schema: {
example: [
{
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
artNo: 'canhydrox-gag',
name: 'Canhydrox GAG (کنهیدروکس)',
scientificTagline: 'برای تقویت مفاصل و استخوان‌ها',
description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...',
shortDescription: 'تقویت مفاصل و غضروف‌ها',
category: 'سیستم حرکتی و مفاصل',
categorySlug: 'joints',
priceValue: '1750000.00',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
unit: 'عدد قرص',
packageSize: '120.00',
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
suitableFor: 'سگ',
imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [],
symptoms: []
}
]
}
})
findAll(@Query() query: GetProductsDto) { findAll(@Query() query: GetProductsDto) {
return this.productsService.findAll(query); return this.productsService.findAll(query);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get Product by ID' }) @ApiOperation({ summary: 'دریافت اطلاعات محصول با شناسه یکتا (ID)' })
@ApiResponse({ status: 200, description: 'Product details' }) @ApiOkResponse({
description: 'اطلاعات کامل محصول شامل مواد تشکیل‌دهنده و علائم مرتبط',
schema: {
example: {
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
artNo: 'canhydrox-gag',
name: 'Canhydrox GAG (کنهیدروکس)',
scientificTagline: 'برای تقویت مفاصل و استخوان‌ها',
description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل...',
shortDescription: 'تقویت مفاصل و غضروف‌ها',
category: 'سیستم حرکتی و مفاصل',
categorySlug: 'joints',
priceValue: '1750000.00',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
unit: 'عدد قرص',
packageSize: '120.00',
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
suitableFor: 'سگ',
imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [
{ productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', ingredient: 'صدف لب‌سبز' }
],
symptoms: [
{ productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', symptom: 'لنگیدن' }
]
}
}
})
@ApiNotFoundResponse({
description: 'محصول با شناسه ارسال شده پیدا نشد',
schema: {
example: {
success: false,
message: 'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found',
code: 'NOT_FOUND',
details: {}
}
}
})
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string) {
const product = await this.productsService.findOne(id); const product = await this.productsService.findOne(id);
if (!product) { if (!product) {

View File

@ -0,0 +1,75 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ProductsService } from './products.service';
import { PrismaService } from '../prisma/prisma.service';
describe('ProductsService', () => {
let service: ProductsService;
let prisma: PrismaService;
const mockPrisma = {
product: {
findMany: jest.fn(),
findUnique: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProductsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<ProductsService>(ProductsService);
prisma = module.get<PrismaService>(PrismaService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should query products with correct filters', async () => {
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-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);
});
});
describe('findOne', () => {
it('should find product by id', async () => {
const prod = { id: 'prod-1' };
mockPrisma.product.findUnique.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);
});
});
});

View File

@ -0,0 +1,56 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisService } from './redis.service';
jest.mock('ioredis', () => {
return jest.fn().mockImplementation(() => {
return {
set: jest.fn(),
get: jest.fn().mockResolvedValue('mock-value'),
del: jest.fn(),
disconnect: jest.fn(),
};
});
});
describe('RedisService', () => {
let service: RedisService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [RedisService],
}).compile();
service = module.get<RedisService>(RedisService);
service.onModuleInit();
});
afterEach(() => {
service.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should call set with or without ttl', async () => {
const setSpy = jest.spyOn((service as any).client, 'set');
await service.set('key', 'val');
expect(setSpy).toHaveBeenCalledWith('key', 'val');
await service.set('key', 'val', 60);
expect(setSpy).toHaveBeenCalledWith('key', 'val', 'EX', 60);
});
it('should call get', async () => {
const getSpy = jest.spyOn((service as any).client, 'get');
const result = await service.get('key');
expect(getSpy).toHaveBeenCalledWith('key');
expect(result).toBe('mock-value');
});
it('should call del', async () => {
const delSpy = jest.spyOn((service as any).client, 'del');
await service.del('key');
expect(delSpy).toHaveBeenCalledWith('key');
});
});

View File

@ -0,0 +1,67 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
describe('SettingsController', () => {
let controller: SettingsController;
let service: SettingsService;
const mockSettingsService = {
getUiTexts: jest.fn().mockResolvedValue([{ key: 'k', value: 'v' }]),
updateUiText: jest.fn().mockResolvedValue({ key: 'k', value: 'v' }),
getScientificTerms: jest.fn().mockResolvedValue([{ key: 'k' }]),
upsertScientificTerm: jest.fn().mockResolvedValue({ key: 'k' }),
deleteScientificTerm: jest.fn().mockResolvedValue({ success: true }),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SettingsController],
providers: [
{ provide: SettingsService, useValue: mockSettingsService },
],
}).compile();
controller = module.get<SettingsController>(SettingsController);
service = module.get<SettingsService>(SettingsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should getUiTexts', async () => {
const result = await controller.getUiTexts();
expect(service.getUiTexts).toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('should updateUiText', async () => {
const result = await controller.updateUiText('k', 'v');
expect(service.updateUiText).toHaveBeenCalledWith('k', 'v');
expect(result.key).toBe('k');
});
it('should getScientificTerms', async () => {
const result = await controller.getScientificTerms();
expect(service.getScientificTerms).toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('should upsertScientificTerm', async () => {
const data = { term: 't', definition: 'd' };
const result = await controller.upsertScientificTerm('k', data);
expect(service.upsertScientificTerm).toHaveBeenCalledWith('k', data);
expect(result.key).toBe('k');
});
it('should deleteScientificTerm', async () => {
const result = await controller.deleteScientificTerm('k');
expect(service.deleteScientificTerm).toHaveBeenCalledWith('k');
expect(result.success).toBe(true);
});
});

View File

@ -1,16 +1,25 @@
import { Controller, Get, Patch, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; import { Controller, Get, Patch, Put, Delete, Body, Param, UseGuards, HttpStatus } from '@nestjs/common';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger';
@ApiTags('Settings') @ApiTags('Settings - تنظیمات متون پویا و واژه‌نامه علمی')
@Controller('settings') @Controller('settings')
export class SettingsController { export class SettingsController {
constructor(private readonly settingsService: SettingsService) {} constructor(private readonly settingsService: SettingsService) {}
@Get('ui-texts') @Get('ui-texts')
@ApiOperation({ summary: 'دریافت تمامی متون رابط کاربری' }) @ApiOperation({ summary: 'دریافت تمامی متون و پیکربندی‌های رابط کاربری' })
@ApiResponse({ status: 200, description: 'تمامی متون بازگردانده شدند' }) @ApiOkResponse({
description: 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها',
schema: {
example: {
hero_badge: "تخصص دارویی از آلمان",
hero_title: "تخصص آلمانی در خدمت سلامت پت‌های خانگی",
hero_desc: "بیش از ۴۰ سال تجربه نوآورانه..."
}
}
})
getUiTexts() { getUiTexts() {
return this.settingsService.getUiTexts(); return this.settingsService.getUiTexts();
} }
@ -18,15 +27,39 @@ export class SettingsController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Patch('ui-texts/:key') @Patch('ui-texts/:key')
@ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری' }) @ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری (نیازمند توکن)' })
@ApiResponse({ status: 200, description: 'متن با موفقیت به‌روزرسانی شد' }) @ApiOkResponse({
description: 'متن با موفقیت به‌روزرسانی شد',
schema: {
example: {
key: 'hero_badge',
value: 'تخصص دارویی ممتاز از آلمان'
}
}
})
@ApiUnauthorizedResponse({
description: 'عدم دسترسی به دلیل عدم احراز هویت',
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
})
updateUiText(@Param('key') key: string, @Body('value') value: string) { updateUiText(@Param('key') key: string, @Body('value') value: string) {
return this.settingsService.updateUiText(key, value); return this.settingsService.updateUiText(key, value);
} }
@Get('scientific-terms') @Get('scientific-terms')
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات علمی' }) @ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژه‌نامه علمی' })
@ApiResponse({ status: 200, description: 'تمامی اصطلاحات علمی بازگردانده شدند' }) @ApiOkResponse({
description: 'لیست کامل اصطلاحات علمی به همراه تعاریف و شناسه‌ها',
schema: {
example: [
{
key: 'green-mussel',
term: 'صدف لب‌سبز (Perna Canaliculus)',
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
wikiId: 'general'
}
]
}
})
getScientificTerms() { getScientificTerms() {
return this.settingsService.getScientificTerms(); return this.settingsService.getScientificTerms();
} }
@ -34,8 +67,22 @@ export class SettingsController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Put('scientific-terms/:key') @Put('scientific-terms/:key')
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' }) @ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' })
@ApiResponse({ status: 200, description: 'اصطلاح علمی ثبت یا ویرایش شد' }) @ApiOkResponse({
description: 'اصطلاح علمی ثبت یا ویرایش شد',
schema: {
example: {
key: 'green-mussel',
term: 'صدف لب‌سبز اصل نیوزیلند',
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
wikiId: 'general'
}
}
})
@ApiUnauthorizedResponse({
description: 'عدم دسترسی',
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
})
upsertScientificTerm(@Param('key') key: string, @Body() data: any) { upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
return this.settingsService.upsertScientificTerm(key, data); return this.settingsService.upsertScientificTerm(key, data);
} }
@ -43,8 +90,20 @@ export class SettingsController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@Delete('scientific-terms/:key') @Delete('scientific-terms/:key')
@ApiOperation({ summary: 'حذف یک اصطلاح علمی' }) @ApiOperation({ summary: 'حذف یک اصطلاح علمی (نیازمند توکن)' })
@ApiResponse({ status: 200, description: 'اصطلاح علمی حذف شد' }) @ApiOkResponse({
description: 'اصطلاح علمی حذف شد',
schema: {
example: {
success: true,
message: 'Scientific term successfully deleted'
}
}
})
@ApiUnauthorizedResponse({
description: 'عدم دسترسی',
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
})
deleteScientificTerm(@Param('key') key: string) { deleteScientificTerm(@Param('key') key: string) {
return this.settingsService.deleteScientificTerm(key); return this.settingsService.deleteScientificTerm(key);
} }

View File

@ -0,0 +1,86 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SettingsService } from './settings.service';
import { PrismaService } from '../prisma/prisma.service';
describe('SettingsService', () => {
let service: SettingsService;
let prisma: PrismaService;
const mockPrisma = {
uiText: {
findMany: jest.fn(),
upsert: jest.fn(),
},
scientificTerm: {
findMany: jest.fn(),
upsert: jest.fn(),
delete: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SettingsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<SettingsService>(SettingsService);
prisma = module.get<PrismaService>(PrismaService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should getUiTexts', async () => {
mockPrisma.uiText.findMany.mockResolvedValue([{ key: 'k', value: 'v' }]);
const result = await service.getUiTexts();
expect(prisma.uiText.findMany).toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('should updateUiText', async () => {
mockPrisma.uiText.upsert.mockResolvedValue({ key: 'k', value: 'v2' });
const result = await service.updateUiText('k', 'v2');
expect(prisma.uiText.upsert).toHaveBeenCalledWith({
where: { key: 'k' },
update: { value: 'v2' },
create: { key: 'k', value: 'v2' },
});
expect(result.value).toBe('v2');
});
it('should getScientificTerms', async () => {
mockPrisma.scientificTerm.findMany.mockResolvedValue([{ key: 'k' }]);
const result = await service.getScientificTerms();
expect(prisma.scientificTerm.findMany).toHaveBeenCalled();
expect(result).toHaveLength(1);
});
it('should upsertScientificTerm', async () => {
mockPrisma.scientificTerm.upsert.mockResolvedValue({ key: 'k', term: 't' });
const data = { term: 't', definition: 'd', wikiId: 'w' };
const result = await service.upsertScientificTerm('k', data);
expect(prisma.scientificTerm.upsert).toHaveBeenCalledWith({
where: { key: 'k' },
update: { term: 't', definition: 'd', wikiId: 'w' },
create: { key: 'k', term: 't', definition: 'd', wikiId: 'w' },
});
expect(result.term).toBe('t');
});
it('should deleteScientificTerm', async () => {
mockPrisma.scientificTerm.delete.mockResolvedValue({ key: 'k' });
const result = await service.deleteScientificTerm('k');
expect(prisma.scientificTerm.delete).toHaveBeenCalledWith({
where: { key: 'k' },
});
expect(result.key).toBe('k');
});
});

View File

@ -0,0 +1,98 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
describe('UsersController', () => {
let controller: UsersController;
let service: UsersService;
const mockUsersService = {
findById: jest.fn().mockResolvedValue({ id: 'user-id', firstName: 'Test' }),
update: jest.fn().mockResolvedValue({ id: 'user-id', firstName: 'Updated' }),
addAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Home' }),
updateAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Work' }),
deleteAddress: jest.fn().mockResolvedValue({ success: true }),
setDefaultAddress: jest.fn().mockResolvedValue({ success: true }),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: mockUsersService },
],
}).compile();
controller = module.get<UsersController>(UsersController);
service = module.get<UsersService>(UsersService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should getProfile', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.getProfile(req);
expect(service.findById).toHaveBeenCalledWith('user-id');
expect(result.id).toBe('user-id');
});
it('should updateProfile', async () => {
const req = { user: { id: 'user-id' } };
const dto = { firstName: 'Updated' };
const result = await controller.updateProfile(req, dto);
expect(service.update).toHaveBeenCalledWith('user-id', dto);
expect(result.firstName).toBe('Updated');
});
it('should addAddress', async () => {
const req = { user: { id: 'user-id' } };
const dto = {
title: 'Home',
receptorName: 'Ali',
phone: '0912',
province: 'Teh',
city: 'Teh',
detail: 'Det',
zipCode: '123',
};
const result = await controller.addAddress(req, dto);
expect(service.addAddress).toHaveBeenCalledWith('user-id', dto);
expect(result.id).toBe('addr-id');
});
it('should updateAddress', async () => {
const req = { user: { id: 'user-id' } };
const dto = {
title: 'Work',
receptorName: 'Ali',
phone: '0912',
province: 'Teh',
city: 'Teh',
detail: 'Det',
zipCode: '123',
};
const result = await controller.updateAddress(req, 'addr-id', dto);
expect(service.updateAddress).toHaveBeenCalledWith('user-id', 'addr-id', dto);
expect(result.title).toBe('Work');
});
it('should deleteAddress', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.deleteAddress(req, 'addr-id');
expect(service.deleteAddress).toHaveBeenCalledWith('user-id', 'addr-id');
expect(result.success).toBe(true);
});
it('should setDefaultAddress', async () => {
const req = { user: { id: 'user-id' } };
const result = await controller.setDefaultAddress(req, 'addr-id');
expect(service.setDefaultAddress).toHaveBeenCalledWith('user-id', 'addr-id');
expect(result.success).toBe(true);
});
});

View File

@ -1,19 +1,50 @@
import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards, Req } from '@nestjs/common'; import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards, Req, HttpStatus } from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse } from '@nestjs/swagger';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import { AddressDto } from './dto/address.dto'; import { AddressDto } from './dto/address.dto';
@ApiTags('Users') @ApiTags('Users - مدیریت کاربران و آدرس‌ها')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('users') @Controller('users')
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است',
schema: {
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
details: {}
}
}
})
export class UsersController { export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('profile') @Get('profile')
@ApiOperation({ summary: 'دریافت پروفایل کاربر فعلی' }) @ApiOperation({ summary: 'دریافت پروفایل کاربر فعلی' })
@ApiOkResponse({
description: 'مشخصات کامل کاربر به همراه آدرس‌ها و اطلاعات حیوانات خانگی',
schema: {
example: {
id: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
firstName: 'کاربر',
lastName: 'جدید',
email: '09123456789@temp.local',
mobile: '09123456789',
role: 'User_PetOwner',
walletBalance: '1500000.00',
charityDonationTotal: '25000.00',
addresses: [],
pets: [],
createdAt: '2026-05-26T15:20:00.000Z',
updatedAt: '2026-05-26T15:20:00.000Z'
}
}
})
getProfile(@Req() req: any) { getProfile(@Req() req: any) {
return this.usersService.findById(req.user.id); return this.usersService.findById(req.user.id);
} }
@ -21,7 +52,34 @@ export class UsersController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Patch('profile') @Patch('profile')
@ApiOperation({ summary: 'ویرایش پروفایل کاربر فعلی' }) @ApiOperation({ summary: 'ویرایش پروفایل کاربر فعلی' })
@ApiResponse({ status: 200, description: 'پروفایل با موفقیت ویرایش شد' }) @ApiOkResponse({
description: 'پروفایل با موفقیت ویرایش شد',
schema: {
example: {
id: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
firstName: 'علی',
lastName: 'احمدی',
email: 'ali@example.com',
mobile: '09123456789',
role: 'User_PetOwner',
walletBalance: '1500000.00',
charityDonationTotal: '25000.00'
}
}
})
@ApiBadRequestResponse({
description: 'خطا در صحت‌سنجی فیلدهای ورودی',
schema: {
example: {
success: false,
message: 'ایمیل نامعتبر است',
code: 'BAD_REQUEST',
details: {
message: ['ایمیل نامعتبر است']
}
}
}
})
updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) { updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) {
return this.usersService.update(req.user.id, updateProfileDto); return this.usersService.update(req.user.id, updateProfileDto);
} }
@ -29,7 +87,24 @@ export class UsersController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Post('addresses') @Post('addresses')
@ApiOperation({ summary: 'ایجاد آدرس جدید برای کاربر' }) @ApiOperation({ summary: 'ایجاد آدرس جدید برای کاربر' })
@ApiResponse({ status: 201, description: 'آدرس جدید با موفقیت ایجاد شد' }) @ApiCreatedResponse({
description: 'آدرس جدید با موفقیت ایجاد شد',
schema: {
example: {
id: 'b5a6c7d8-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
title: 'خانه',
receptorName: 'علی احمدی',
phone: '09123456789',
province: 'تهران',
city: 'تهران',
detail: 'خیابان آزادی، کوچه مریم، پلاک ۱۰',
zipCode: '1456789012',
isDefault: false,
createdAt: '2026-05-26T18:00:00.000Z'
}
}
})
addAddress(@Req() req: any, @Body() addressDto: AddressDto) { addAddress(@Req() req: any, @Body() addressDto: AddressDto) {
return this.usersService.addAddress(req.user.id, addressDto); return this.usersService.addAddress(req.user.id, addressDto);
} }
@ -37,7 +112,24 @@ export class UsersController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Patch('addresses/:addressId') @Patch('addresses/:addressId')
@ApiOperation({ summary: 'ویرایش آدرس کاربر' }) @ApiOperation({ summary: 'ویرایش آدرس کاربر' })
@ApiResponse({ status: 200, description: 'آدرس با موفقیت ویرایش شد' }) @ApiOkResponse({
description: 'آدرس با موفقیت ویرایش شد',
schema: {
example: {
id: 'b5a6c7d8-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
title: 'دفتر کار',
receptorName: 'علی احمدی',
phone: '09123456789',
province: 'تهران',
city: 'تهران',
detail: 'خیابان ولیعصر، برج سپهر، طبقه ۴',
zipCode: '1456789012',
isDefault: false,
createdAt: '2026-05-26T18:00:00.000Z'
}
}
})
updateAddress( updateAddress(
@Req() req: any, @Req() req: any,
@Param('addressId') addressId: string, @Param('addressId') addressId: string,
@ -49,7 +141,15 @@ export class UsersController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Delete('addresses/:addressId') @Delete('addresses/:addressId')
@ApiOperation({ summary: 'حذف آدرس کاربر' }) @ApiOperation({ summary: 'حذف آدرس کاربر' })
@ApiResponse({ status: 200, description: 'آدرس با موفقیت حذف شد' }) @ApiOkResponse({
description: 'آدرس با موفقیت حذف شد',
schema: {
example: {
success: true,
message: 'آدرس با موفقیت حذف شد'
}
}
})
deleteAddress(@Req() req: any, @Param('addressId') addressId: string) { deleteAddress(@Req() req: any, @Param('addressId') addressId: string) {
return this.usersService.deleteAddress(req.user.id, addressId); return this.usersService.deleteAddress(req.user.id, addressId);
} }
@ -57,7 +157,15 @@ export class UsersController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Patch('addresses/:addressId/default') @Patch('addresses/:addressId/default')
@ApiOperation({ summary: 'انتخاب آدرس به عنوان پیش‌فرض' }) @ApiOperation({ summary: 'انتخاب آدرس به عنوان پیش‌فرض' })
@ApiResponse({ status: 200, description: 'آدرس به عنوان پیش‌فرض ثبت شد' }) @ApiOkResponse({
description: 'آدرس به عنوان پیش‌فرض ثبت شد',
schema: {
example: {
success: true,
message: 'آدرس پیش‌فرض با موفقیت تغییر کرد'
}
}
})
setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) { setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) {
return this.usersService.setDefaultAddress(req.user.id, addressId); return this.usersService.setDefaultAddress(req.user.id, addressId);
} }

View File

@ -0,0 +1,101 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
describe('UsersService', () => {
let service: UsersService;
let prisma: PrismaService;
const mockPrisma = {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
userAddress: {
updateMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<UsersService>(UsersService);
prisma = module.get<PrismaService>(PrismaService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should call prisma findUnique in findById', async () => {
const mockUser = { id: 'user-id' };
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const result = await service.findById('user-id');
expect(prisma.user.findUnique).toHaveBeenCalled();
expect(result).toEqual(mockUser);
});
it('should call prisma update in update', async () => {
const data = { firstName: 'Ali' };
mockPrisma.user.update.mockResolvedValue({ id: 'user-id', ...data });
const result = await service.update('user-id', data);
expect(prisma.user.update).toHaveBeenCalled();
expect(result.firstName).toBe('Ali');
});
it('should addAddress', async () => {
const addressData = { title: 'Home', isDefault: true };
mockPrisma.userAddress.create.mockResolvedValue({ id: 'addr-id', ...addressData });
const result = await service.addAddress('user-id', addressData);
expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-id' },
data: { isDefault: false },
});
expect(prisma.userAddress.create).toHaveBeenCalled();
expect(result.id).toBe('addr-id');
});
it('should updateAddress', async () => {
const addressData = { title: 'Work', isDefault: true };
mockPrisma.userAddress.update.mockResolvedValue({ id: 'addr-id', ...addressData });
const result = await service.updateAddress('user-id', 'addr-id', addressData);
expect(prisma.userAddress.updateMany).toHaveBeenCalled();
expect(prisma.userAddress.update).toHaveBeenCalled();
expect(result.title).toBe('Work');
});
it('should deleteAddress', async () => {
mockPrisma.userAddress.delete.mockResolvedValue({ id: 'addr-id' });
const result = await service.deleteAddress('user-id', 'addr-id');
expect(prisma.userAddress.delete).toHaveBeenCalled();
expect(result.id).toBe('addr-id');
});
it('should setDefaultAddress', async () => {
mockPrisma.userAddress.update.mockResolvedValue({ id: 'addr-id', isDefault: true });
const result = await service.setDefaultAddress('user-id', 'addr-id');
expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-id' },
data: { isDefault: false },
});
expect(prisma.userAddress.update).toHaveBeenCalledWith({
where: { id: 'addr-id', userId: 'user-id' },
data: { isDefault: true },
});
expect(result.isDefault).toBe(true);
});
});

View File

@ -45,9 +45,31 @@ services:
container_name: canina_frontend container_name: canina_frontend
restart: always restart: always
ports: ports:
- "3001:80" - "3001:8080"
depends_on: depends_on:
- backend - backend
prometheus:
image: prom/prometheus:v2.51.0
container_name: canina_prometheus
restart: always
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
depends_on:
- backend
grafana:
image: grafana/grafana:10.4.1
container_name: canina_grafana
restart: always
ports:
- "3002:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
depends_on:
- prometheus
volumes: volumes:
postgres_data: postgres_data:

View File

@ -1,9 +1,17 @@
server { server {
listen 80; listen 8080;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Security Headers
server_tokens off;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval' https://images.unsplash.com https://canina.de;" always;
# Support for SPA routing # Support for SPA routing
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

1154
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,9 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist server.js", "clean": "rm -rf dist server.js",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
@ -26,15 +28,20 @@
"zustand": "^5.0.13" "zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"jsdom": "^29.1.1",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.3" "vite": "^6.2.3",
"vitest": "^4.1.7"
} }
} }

8
prometheus.yml Normal file
View File

@ -0,0 +1,8 @@
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'nest-backend'
metrics_path: '/api/metrics'
static_configs:
- targets: ['backend:3000']

View File

@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { PRODUCTS, Product } from "../data/products"; import { Product } from "../data/products";
import { useCartStore } from "../store/cartStore"; import { useCartStore } from "../store/cartStore";
import { productService } from "../services/productService";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { import {
Building2, Building2,
@ -20,12 +21,17 @@ interface QuickOrderItem {
} }
export default function B2BPortal({ onClose }: { onClose: () => void }) { export default function B2BPortal({ onClose }: { onClose: () => void }) {
const [products, setProducts] = useState<Product[]>([]);
const [quantities, setQuantities] = useState<Record<string, number>>({}); const [quantities, setQuantities] = useState<Record<string, number>>({});
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const { addItem } = useCartStore(); const { addItem } = useCartStore();
const [showSuccess, setShowSuccess] = useState(false); const [showSuccess, setShowSuccess] = useState(false);
const filteredProducts = PRODUCTS.filter(p => useEffect(() => {
productService.getProducts().then(data => setProducts(data));
}, []);
const filteredProducts = products.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) || p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.artNo.includes(searchQuery) p.artNo.includes(searchQuery)
); );
@ -39,7 +45,7 @@ export default function B2BPortal({ onClose }: { onClose: () => void }) {
Object.entries(quantities).forEach(([id, qty]) => { Object.entries(quantities).forEach(([id, qty]) => {
const numQty = qty as number; const numQty = qty as number;
if (numQty > 0) { if (numQty > 0) {
const product = PRODUCTS.find(p => p.id === id); const product = products.find(p => p.id === id);
if (product) { if (product) {
addItem(product, numQty); addItem(product, numQty);
} }

View File

@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { ChevronRight, Calendar, User, ArrowLeft, Sparkles, BookOpen } from "lucide-react"; import { ChevronRight, Calendar, User, ArrowLeft, Sparkles, BookOpen } from "lucide-react";
import { Product, PRODUCTS } from "../data/products"; import { Product } from "../data/products";
import { productService } from "../services/productService";
const BLOG_POSTS = [ const BLOG_POSTS = [
{ {
@ -42,6 +43,11 @@ const BLOG_POSTS = [
export default function BlogPage({ onBack, onProductClick }: { onBack: () => void, onProductClick: (p: Product) => void }) { export default function BlogPage({ onBack, onProductClick }: { onBack: () => void, onProductClick: (p: Product) => void }) {
const [selectedPost, setSelectedPost] = React.useState<typeof BLOG_POSTS[0] | null>(null); const [selectedPost, setSelectedPost] = React.useState<typeof BLOG_POSTS[0] | null>(null);
const [products, setProducts] = React.useState<Product[]>([]);
React.useEffect(() => {
productService.getProducts().then(data => setProducts(data));
}, []);
if (selectedPost) { if (selectedPost) {
return ( return (
@ -113,7 +119,7 @@ export default function BlogPage({ onBack, onProductClick }: { onBack: () => voi
</div> </div>
<h4 className="text-2xl font-black mb-6 italic relative z-10">محصولات پیشنهادی</h4> <h4 className="text-2xl font-black mb-6 italic relative z-10">محصولات پیشنهادی</h4>
<div className="space-y-6 relative z-10"> <div className="space-y-6 relative z-10">
{PRODUCTS.slice(4, 7).map(p => ( {products.slice(4, 7).map(p => (
<div <div
key={p.id} key={p.id}
onClick={() => onProductClick(p)} onClick={() => onProductClick(p)}
@ -316,7 +322,7 @@ export default function BlogPage({ onBack, onProductClick }: { onBack: () => voi
</button> </button>
</div> </div>
<div className="grid grid-cols-2 gap-4 flex-shrink-0"> <div className="grid grid-cols-2 gap-4 flex-shrink-0">
{PRODUCTS.slice(0, 2).map((p) => ( {products.slice(0, 2).map((p) => (
<div <div
key={p.id} key={p.id}
onClick={() => onProductClick(p)} onClick={() => onProductClick(p)}

View File

@ -1,9 +1,10 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { X, Trash2, Plus, Minus, ShoppingBag, ShieldCheck, ArrowLeft, Ticket, Sparkles, CheckCircle2 } from "lucide-react"; import { X, Trash2, Plus, Minus, ShoppingBag, ShieldCheck, ArrowLeft, Ticket, Sparkles, CheckCircle2 } from "lucide-react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { toPersian, cn } from "../lib/utils"; import { toPersian, cn } from "../lib/utils";
import { useCartStore } from "../store/cartStore"; import { useCartStore } from "../store/cartStore";
import { PRODUCTS } from "../data/products"; import { Product } from "../data/products";
import { productService } from "../services/productService";
import SafeImage from "./SafeImage"; import SafeImage from "./SafeImage";
import { toast } from "sonner"; import { toast } from "sonner";
@ -32,10 +33,18 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate
// Smart Cross-sell Logic // Smart Cross-sell Logic
const hasCanhydrox = items.some(i => i.product.id === 'canhydrox-gag'); const hasCanhydrox = items.some(i => i.product.id === 'canhydrox-gag');
const hasLachsOl = items.some(i => i.product.id === 'lachs-ol'); const hasLachsOl = items.some(i => i.product.id === 'lachs-ol');
const [suggestedProduct, setSuggestedProduct] = useState<Product | null>(null);
const suggestedProduct = (!hasLachsOl && hasCanhydrox)
? PRODUCTS.find(p => p.id === 'lachs-ol') useEffect(() => {
: null; if (!hasLachsOl && hasCanhydrox) {
productService.getProducts().then(prods => {
const found = prods.find(p => p.id === 'lachs-ol' || p.artNo === 'lachs-ol');
setSuggestedProduct(found || null);
});
} else {
setSuggestedProduct(null);
}
}, [hasLachsOl, hasCanhydrox]);
return ( return (
<AnimatePresence> <AnimatePresence>
@ -103,6 +112,7 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate
<button <button
onClick={() => updateQuantity(item.product.id, item.quantity - 1)} onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
className="p-1 hover:text-canina-blue transition-colors" className="p-1 hover:text-canina-blue transition-colors"
data-testid="minus-btn"
> >
<Minus className="w-3 h-3" /> <Minus className="w-3 h-3" />
</button> </button>
@ -110,6 +120,7 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate
<button <button
onClick={() => updateQuantity(item.product.id, item.quantity + 1)} onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
className="p-1 hover:text-canina-blue transition-colors" className="p-1 hover:text-canina-blue transition-colors"
data-testid="plus-btn"
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
</button> </button>

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { import {
ChevronRight, ChevronRight,
@ -19,7 +19,8 @@ import { toPersian, cn } from "../lib/utils";
import { useCartStore } from "../store/cartStore"; import { useCartStore } from "../store/cartStore";
import { usePetStore } from "../store/usePetStore"; import { usePetStore } from "../store/usePetStore";
import { useUserStore } from "../store/userStore"; import { useUserStore } from "../store/userStore";
import { PRODUCTS } from "../data/products"; import { Product } from "../data/products";
import { productService } from "../services/productService";
import { toast } from "sonner"; import { toast } from "sonner";
export default function CheckoutPage({ onBack, onComplete }: { onBack: () => void; onComplete: (orderId: string) => void }) { export default function CheckoutPage({ onBack, onComplete }: { onBack: () => void; onComplete: (orderId: string) => void }) {
@ -29,6 +30,11 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [useRoundUp, setUseRoundUp] = useState(false); const [useRoundUp, setUseRoundUp] = useState(false);
const [dbProducts, setDbProducts] = useState<Product[]>([]);
useEffect(() => {
productService.getProducts().then(data => setDbProducts(data));
}, []);
const subtotal = getSubtotal(); const subtotal = getSubtotal();
const roundedAmount = Math.ceil(subtotal / 10000) * 10000; const roundedAmount = Math.ceil(subtotal / 10000) * 10000;
@ -329,7 +335,7 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi
{items.map(item => { {items.map(item => {
const activePet = usePetStore.getState().getActivePet(); const activePet = usePetStore.getState().getActivePet();
// Re-hydrate product to ensure methods like calculateDosage exist // Re-hydrate product to ensure methods like calculateDosage exist
const fullProduct = PRODUCTS.find(p => p.id === item.product.id) || item.product; const fullProduct = dbProducts.find(p => p.id === item.product.id) || item.product;
const dose = activePet ? fullProduct.calculateDosage(activePet.weight, activePet.age <= 1) : { quantity: 1 }; const dose = activePet ? fullProduct.calculateDosage(activePet.weight, activePet.age <= 1) : { quantity: 1 };
const days = Math.floor(fullProduct.packageSize / (dose.quantity || 1)); const days = Math.floor(fullProduct.packageSize / (dose.quantity || 1));

View File

@ -1,12 +1,18 @@
import { useMemo } from "react"; import { useMemo, useState, useEffect } from "react";
import { INGREDIENTS_WIKI, PRODUCTS, Product } from "../data/products"; import { INGREDIENTS_WIKI, Product } from "../data/products";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { FlaskConical, CheckCircle2, ChevronRight, ChevronLeft, Beaker } from "lucide-react"; import { FlaskConical, CheckCircle2, ChevronRight, ChevronLeft, Beaker } from "lucide-react";
import { useSettingsStore } from "../store/settingsStore"; import { useSettingsStore } from "../store/settingsStore";
import { productService } from "../services/productService";
export default function IngredientWiki({ onProductClick, onBack }: { onProductClick: (p: Product) => void, onBack?: () => void }) { export default function IngredientWiki({ onProductClick, onBack }: { onProductClick: (p: Product) => void, onBack?: () => void }) {
const texts = useSettingsStore(state => state.texts); const texts = useSettingsStore(state => state.texts);
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
productService.getProducts().then(data => setProducts(data));
}, []);
const ingredientsWiki = useMemo(() => { const ingredientsWiki = useMemo(() => {
const jsonStr = texts['ingredients_wiki']; const jsonStr = texts['ingredients_wiki'];
if (jsonStr) { if (jsonStr) {
@ -62,9 +68,9 @@ export default function IngredientWiki({ onProductClick, onBack }: { onProductCl
<div className="space-y-32"> <div className="space-y-32">
{ingredientsWiki.map((ing, idx) => { {ingredientsWiki.map((ing, idx) => {
const hasIngredient = PRODUCTS.filter(p => const hasIngredient = products.filter(p =>
p.main_ingredients.some(mi => mi.toLowerCase().includes(ing.name.toLowerCase())) || p.main_ingredients?.some(mi => mi.toLowerCase().includes(ing.name.toLowerCase())) ||
p.main_ingredients.some(mi => mi.toLowerCase().includes(ing.id.replace('-', ' '))) p.main_ingredients?.some(mi => mi.toLowerCase().includes(ing.id.replace('-', ' ')))
); );
return ( return (

View File

@ -1,5 +1,6 @@
import React, { useState, useMemo, useEffect } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { PRODUCTS, Product, PetType } from "../data/products"; import { Product, PetType } from "../data/products";
import { productService } from "../services/productService";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { PetProfileSkeleton } from "./Skeleton"; import { PetProfileSkeleton } from "./Skeleton";
import { import {
@ -43,6 +44,13 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
const { orders } = useCartStore(); const { orders } = useCartStore();
const activePet = getActivePet(); const activePet = getActivePet();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [products, setProducts] = useState<Product[]>([]);
useEffect(() => {
productService.getProducts()
.then(setProducts)
.catch(err => console.error("Error fetching products in PetProfile:", err));
}, []);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 800); const timer = setTimeout(() => setIsLoading(false), 800);
@ -151,50 +159,50 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
}; };
const recommendedProducts = useMemo(() => { const recommendedProducts = useMemo(() => {
if (!activePet) return []; if (!activePet || products.length === 0) return [];
let picks: { product: Product, reason?: string }[] = []; let picks: { product: Product, reason?: string }[] = [];
// Priority: Medical Conditions // Priority: Medical Conditions
if (activePet.medicalConditions.includes("جراحی مفاصل")) { if (activePet.medicalConditions.includes("جراحی مفاصل")) {
const gag = PRODUCTS.find(p => p.id === "canhydrox-gag"); const gag = products.find(p => p.id === "canhydrox-gag");
if (gag) picks.push({ product: gag, reason: "پیشنهاد ویژه بر اساس سوابق جراحی مفاصل" }); if (gag) picks.push({ product: gag, reason: "پیشنهاد ویژه بر اساس سوابق جراحی مفاصل" });
} }
if (activePet.medicalConditions.includes("زایمان اخیر") || activePet.medicalConditions.includes("بارداری")) { if (activePet.medicalConditions.includes("زایمان اخیر") || activePet.medicalConditions.includes("بارداری")) {
const booster = PRODUCTS.find(p => p.id === "immun-booster"); const booster = products.find(p => p.id === "immun-booster");
if (booster) picks.push({ product: booster, reason: "حمایت حیاتی دوران بارداری و زایمان" }); if (booster) picks.push({ product: booster, reason: "حمایت حیاتی دوران بارداری و زایمان" });
} }
if (activePet.medicalConditions.includes("مشکلات گوارشی")) { if (activePet.medicalConditions.includes("مشکلات گوارشی")) {
const moor = PRODUCTS.find(p => p.id === "moor-tranke"); const moor = products.find(p => p.id === "moor-tranke");
if (moor) picks.push({ product: moor, reason: "تنظیم تخصصی سیستم گوارش" }); if (moor) picks.push({ product: moor, reason: "تنظیم تخصصی سیستم گوارش" });
} }
if (activePet.medicalConditions.includes("ریزش موی شدید")) { if (activePet.medicalConditions.includes("ریزش موی شدید")) {
const lachs = PRODUCTS.find(p => p.id === "lachs-ol"); const lachs = products.find(p => p.id === "lachs-ol");
if (lachs) picks.push({ product: lachs, reason: "تقویت تارهای مو و کنترل ریزش" }); if (lachs) picks.push({ product: lachs, reason: "تقویت تارهای مو و کنترل ریزش" });
} }
if (activePet.medicalConditions.includes("بی‌اشتهایی")) { if (activePet.medicalConditions.includes("بی‌اشتهایی")) {
const energy = PRODUCTS.find(p => p.id === "energy-gel"); const energy = products.find(p => p.id === "energy-gel");
if (energy) picks.push({ product: energy, reason: "تامین فوری انرژی و تحریک اشتها" }); if (energy) picks.push({ product: energy, reason: "تامین فوری انرژی و تحریک اشتها" });
} }
// Biological factors // Biological factors
if (activePet.age > 7) { if (activePet.age > 7) {
const heart = PRODUCTS.find(p => p.id === "herz-vital"); const heart = products.find(p => p.id === "herz-vital");
if (heart && !picks.some(x => x.product.id === heart.id)) picks.push({ product: heart, reason: "محافظت از قلب پت‌های مسن" }); if (heart && !picks.some(x => x.product.id === heart.id)) picks.push({ product: heart, reason: "محافظت از قلب پت‌های مسن" });
} }
// Fill defaults if needed // Fill defaults if needed
if (picks.length < 4) { if (picks.length < 4) {
const defaults = PRODUCTS.filter(p => !picks.some(x => x.product.id === p.id) && (p.suitableFor === activePet.type || p.suitableFor === "هر دو")); const defaults = products.filter(p => !picks.some(x => x.product.id === p.id) && (p.suitableFor === activePet.type || p.suitableFor === "هر دو"));
defaults.slice(0, 4 - picks.length).forEach(p => picks.push({ product: p })); defaults.slice(0, 4 - picks.length).forEach(p => picks.push({ product: p }));
} }
return picks; return picks;
}, [activePet]); }, [activePet, products]);
const handleNext = () => setStep(s => s + 1); const handleNext = () => setStep(s => s + 1);
const handleBackStep = () => setStep(s => s - 1); const handleBackStep = () => setStep(s => s - 1);
@ -716,7 +724,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
</div> </div>
) : ( ) : (
activePet.consumptions.map((consumption) => { activePet.consumptions.map((consumption) => {
const product = PRODUCTS.find(p => p.id === consumption.productId); const product = products.find(p => p.id === consumption.productId);
if (!product) return null; if (!product) return null;
const percentage = (consumption.remaining / (consumption.packageSize || 1)) * 100; const percentage = (consumption.remaining / (consumption.packageSize || 1)) * 100;

View File

@ -40,7 +40,8 @@ const ICON_MAP: Record<string, any> = {
Users Users
}; };
import { toPersian, cn } from "../lib/utils"; import { toPersian, cn } from "../lib/utils";
import { Product, PRODUCTS } from "../data/products"; import { Product } from "../data/products";
import { productService } from "../services/productService";
import { SCIENTIFIC_TERMS } from "../data/scientificTerms"; import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
import { useSettingsStore } from "../store/settingsStore"; import { useSettingsStore } from "../store/settingsStore";
import { create } from "zustand"; import { create } from "zustand";
@ -75,6 +76,13 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
const [activeTab, setActiveTab] = useState<"specs" | "feeding" | "notes">("specs"); const [activeTab, setActiveTab] = useState<"specs" | "feeding" | "notes">("specs");
const [showRefillModal, setShowRefillModal] = useState(false); const [showRefillModal, setShowRefillModal] = useState(false);
const [itemQuantity, setItemQuantity] = useState(1); const [itemQuantity, setItemQuantity] = useState(1);
const [allProducts, setAllProducts] = useState<Product[]>([]);
useEffect(() => {
productService.getProducts()
.then(setAllProducts)
.catch(err => console.error("Error fetching products in ProductPage:", err));
}, []);
const { pets, getActivePet } = usePetStore(); const { pets, getActivePet } = usePetStore();
const activePet = getActivePet(); const activePet = getActivePet();
const { petType, weight, setPetType, setWeight } = useCalculatorStore(); const { petType, weight, setPetType, setWeight } = useCalculatorStore();
@ -89,8 +97,8 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
// Re-hydrate product to ensure methods like calculateDosage exist // Re-hydrate product to ensure methods like calculateDosage exist
const fullProduct = useMemo(() => { const fullProduct = useMemo(() => {
return PRODUCTS.find(p => p.id === product.id) || product; return allProducts.find(p => p.id === product.id) || product;
}, [product]); }, [product, allProducts]);
const syncActivePet = useMemo(() => { const syncActivePet = useMemo(() => {
if (activePet) { if (activePet) {
@ -490,7 +498,7 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
<div className="grid lg:grid-cols-2 gap-8"> <div className="grid lg:grid-cols-2 gap-8">
{product.relatedProducts.map(relId => { {product.relatedProducts.map(relId => {
const relProduct = PRODUCTS.find(p => p.id === relId); const relProduct = allProducts.find(p => p.id === relId);
if (!relProduct) return null; if (!relProduct) return null;
return ( return (
<motion.div <motion.div

View File

@ -1,21 +1,29 @@
import React, { useMemo } from "react"; import React, { useMemo, useState, useEffect } from "react";
import { Search, ShoppingBag, ChevronLeft, ArrowRight, Activity, Sparkles, HeartPulse, Stethoscope, ShieldCheck, Heart, AlertCircle } from "lucide-react"; import { Search, ShoppingBag, ChevronLeft, ArrowRight, Activity, Sparkles, HeartPulse, Stethoscope, ShieldCheck, Heart, AlertCircle } from "lucide-react";
import { usePetStore } from "../store/usePetStore"; import { usePetStore } from "../store/usePetStore";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Product, PRODUCTS } from "../data/products"; import { Product } from "../data/products";
import { productService } from "../services/productService";
export default function SearchResultsPage({ query, onProductClick, onBack }: { query: string; onProductClick: (p: Product) => void; onBack: () => void }) { export default function SearchResultsPage({ query, onProductClick, onBack }: { query: string; onProductClick: (p: Product) => void; onBack: () => void }) {
const { getActivePet } = usePetStore(); const { getActivePet } = usePetStore();
const activePet = getActivePet(); const activePet = getActivePet();
const [products, setProducts] = useState<Product[]>([]);
const results = PRODUCTS.filter(p => useEffect(() => {
productService.getProducts()
.then(setProducts)
.catch(err => console.error("Error fetching products in SearchResultsPage:", err));
}, []);
const results = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase()) || p.name.toLowerCase().includes(query.toLowerCase()) ||
p.description.toLowerCase().includes(query.toLowerCase()) || p.description.toLowerCase().includes(query.toLowerCase()) ||
p.main_ingredients.some(ing => ing.toLowerCase().includes(query.toLowerCase())) || p.main_ingredients.some(ing => ing.toLowerCase().includes(query.toLowerCase())) ||
p.symptoms.some(sym => sym.toLowerCase().includes(query.toLowerCase())) p.symptoms.some(sym => sym.toLowerCase().includes(query.toLowerCase()))
); );
const bestSellers = PRODUCTS.slice(0, 3); const bestSellers = products.slice(0, 3);
return ( return (
<div className="min-h-screen bg-medical-gray-50 py-20 px-6 font-vazir" dir="rtl"> <div className="min-h-screen bg-medical-gray-50 py-20 px-6 font-vazir" dir="rtl">

View File

@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react"; import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react";
import { Product, PRODUCTS } from "../data/products";
const ALL_VIDEOS = [ const ALL_VIDEOS = [
{ {

View File

@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import CartDrawer from '../CartDrawer';
import { useCartStore } from '../../store/cartStore';
import { productService } from '../../services/productService';
vi.mock('../../store/cartStore', () => ({
useCartStore: vi.fn(),
}));
vi.mock('../../services/productService', () => ({
productService: {
getProducts: vi.fn(),
},
}));
const mockProduct = {
id: 'canhydrox-gag',
name: 'Canhydrox GAG',
price: '۱,۰۰۰ تومان',
priceValue: 1000,
category: 'joints',
image: '',
};
describe('CartDrawer', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(productService.getProducts).mockResolvedValue([]);
});
it('renders empty cart state when no items in cart', () => {
vi.mocked(useCartStore).mockReturnValue({
items: [],
updateQuantity: vi.fn(),
removeItem: vi.fn(),
isSubscribed: false,
toggleSubscription: vi.fn(),
getTotal: () => 0,
getSubtotal: () => 0,
getDiscount: () => 0,
coupon: null,
applyCoupon: vi.fn(),
addItem: vi.fn(),
} as any);
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
expect(screen.getByText('سبد خرید شما خالی است')).toBeInTheDocument();
});
it('renders cart items and total when items are present', () => {
vi.mocked(useCartStore).mockReturnValue({
items: [{ product: mockProduct, quantity: 2 }],
updateQuantity: vi.fn(),
removeItem: vi.fn(),
isSubscribed: false,
toggleSubscription: vi.fn(),
getTotal: () => 2000,
getSubtotal: () => 2000,
getDiscount: () => 0,
coupon: null,
applyCoupon: vi.fn(),
addItem: vi.fn(),
} as any);
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
expect(screen.getAllByText('۲,۰۰۰').length).toBeGreaterThanOrEqual(1);
});
it('triggers updateQuantity when plus/minus buttons are clicked', () => {
const mockUpdateQuantity = vi.fn();
vi.mocked(useCartStore).mockReturnValue({
items: [{ product: mockProduct, quantity: 2 }],
updateQuantity: mockUpdateQuantity,
removeItem: vi.fn(),
isSubscribed: false,
toggleSubscription: vi.fn(),
getTotal: () => 2000,
getSubtotal: () => 2000,
getDiscount: () => 0,
coupon: null,
applyCoupon: vi.fn(),
addItem: vi.fn(),
} as any);
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
const plusBtn = screen.getByTestId('plus-btn');
const minusBtn = screen.getByTestId('minus-btn');
fireEvent.click(plusBtn);
expect(mockUpdateQuantity).toHaveBeenCalledWith('canhydrox-gag', 3);
fireEvent.click(minusBtn);
expect(mockUpdateQuantity).toHaveBeenCalledWith('canhydrox-gag', 1);
});
});

View File

@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import FeaturedProducts from '../FeaturedProducts';
import { productService } from '../../services/productService';
import { usePetStore } from '../../store/usePetStore';
vi.mock('../../services/productService', () => ({
productService: {
getFeaturedProducts: vi.fn(),
},
}));
vi.mock('../../store/usePetStore', () => ({
usePetStore: vi.fn(),
}));
const mockProducts = [
{
id: 'prod-1',
name: 'Canhydrox GAG',
description: 'Joint helper',
price: '۱,۰۰۰ تومان',
priceValue: 1000,
category: 'joints',
image: '',
suitableFor: 'سگ',
symptoms: ['joint_pain'],
benefits: 'Strengthens joints',
calculateDosage: vi.fn(),
},
];
describe('FeaturedProducts', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(usePetStore).mockReturnValue({
getActivePet: () => null,
} as any);
});
it('renders loading skeletons initially', () => {
vi.mocked(productService.getFeaturedProducts).mockReturnValue(new Promise(() => {}));
const { container } = render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
// Check if skeletons are present (e.g. searching for animate-pulse)
expect(container.getElementsByClassName('animate-pulse').length).toBeGreaterThan(0);
});
it('renders products once loaded', async () => {
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
});
expect(screen.getByText('Joint helper')).toBeInTheDocument();
});
it('calls onProductClick when product card is clicked', async () => {
const handleProductClick = vi.fn();
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Canhydrox GAG'));
expect(handleProductClick).toHaveBeenCalledWith(mockProducts[0]);
});
it('calls onShopNavigate when navigation link is clicked', async () => {
const handleShopNavigate = vi.fn();
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
const navBtn = screen.getByText('مشاهده تمامی محصولات');
fireEvent.click(navBtn);
expect(handleShopNavigate).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,43 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import Footer from '../Footer';
describe('Footer', () => {
it('renders footer brand text and standard layout elements', () => {
render(<Footer onNavigate={vi.fn()} onShopNavigate={vi.fn()} onB2BOpen={vi.fn()} />);
expect(screen.getByText('کانینا ایران')).toBeInTheDocument();
expect(screen.getByText('نماینده رسمی در ایران')).toBeInTheDocument();
expect(screen.getByText('تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹')).toBeInTheDocument();
});
it('triggers onNavigate and onShopNavigate when quick links are clicked', () => {
const handleNavigate = vi.fn();
const handleShopNavigate = vi.fn();
const handleB2BOpen = vi.fn();
render(
<Footer
onNavigate={handleNavigate}
onShopNavigate={handleShopNavigate}
onB2BOpen={handleB2BOpen}
/>
);
// Click quick link for shop
const shopLink = screen.getByText('محصولات تخصصی ۲۰۲۴');
fireEvent.click(shopLink);
expect(handleShopNavigate).toHaveBeenCalled();
// Click quick link for blog
const blogLink = screen.getByText('مجله سلامت پت (وبلاگ)');
fireEvent.click(blogLink);
expect(handleNavigate).toHaveBeenCalledWith('blog');
// Click B2B link
const b2bLink = screen.getByText('پنل سفارش عمده (B2B)');
fireEvent.click(b2bLink);
expect(handleB2BOpen).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import Header from '../Header';
import { useCartStore } from '../../store/cartStore';
import { usePetStore } from '../../store/usePetStore';
import { useUserStore } from '../../store/userStore';
vi.mock('../../store/cartStore', () => ({
useCartStore: vi.fn(),
}));
vi.mock('../../store/usePetStore', () => ({
usePetStore: vi.fn(),
}));
vi.mock('../../store/userStore', () => ({
useUserStore: vi.fn(),
}));
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useCartStore).mockReturnValue({
getTotalItems: () => 3,
} as any);
vi.mocked(usePetStore).mockReturnValue({
pets: [],
activePetId: null,
setActivePet: vi.fn(),
getActivePet: () => null,
} as any);
vi.mocked(useUserStore).mockReturnValue({
role: 'User_Guest',
isLoggedIn: false,
logout: vi.fn(),
profile: { firstName: '' },
} as any);
});
it('renders brand name and login button when guest', () => {
render(
<Header
onNavigate={vi.fn()}
onShopNavigate={vi.fn()}
currentView="home"
onCartOpen={vi.fn()}
onSearch={vi.fn()}
onB2BOpen={vi.fn()}
/>
);
expect(screen.getByText('Canina')).toBeInTheDocument();
expect(screen.getByText('ایران')).toBeInTheDocument();
expect(screen.getByText('ورود / ثبت‌نام')).toBeInTheDocument();
});
it('renders user first name and pet selection when logged in', () => {
vi.mocked(useUserStore).mockReturnValue({
role: 'User_PetOwner',
isLoggedIn: true,
logout: vi.fn(),
profile: { firstName: 'کوروش' },
} as any);
vi.mocked(usePetStore).mockReturnValue({
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
activePetId: 'pet-1',
setActivePet: vi.fn(),
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
} as any);
render(
<Header
onNavigate={vi.fn()}
onShopNavigate={vi.fn()}
currentView="home"
onCartOpen={vi.fn()}
onSearch={vi.fn()}
onB2BOpen={vi.fn()}
/>
);
expect(screen.queryByText('ورود / ثبت‌نام')).not.toBeInTheDocument();
expect(screen.getByText('کوروش')).toBeInTheDocument();
expect(screen.getByText('ملوس')).toBeInTheDocument();
});
it('calls onCartOpen when click on cart button', () => {
const handleCartOpen = vi.fn();
render(
<Header
onNavigate={vi.fn()}
onShopNavigate={vi.fn()}
currentView="home"
onCartOpen={handleCartOpen}
onSearch={vi.fn()}
onB2BOpen={vi.fn()}
/>
);
const cartBtn = screen.getByText('سبد خرید').closest('button');
expect(cartBtn).toBeInTheDocument();
fireEvent.click(cartBtn!);
expect(handleCartOpen).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import Hero from '../Hero';
import { useSettingsStore } from '../../store/settingsStore';
vi.mock('../../store/settingsStore', () => ({
useSettingsStore: vi.fn(),
}));
describe('Hero', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders title and description from settingsStore', () => {
const mockGetText = vi.fn().mockImplementation((key, fallback) => {
if (key === 'hero_title') return 'Test Title Line 1\nTest Title Line 2';
if (key === 'hero_desc') return 'Test Description Text';
return fallback;
});
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
render(<Hero onShopNavigate={vi.fn()} />);
expect(screen.getByText('Test Title Line 1')).toBeInTheDocument();
expect(screen.getByText('Test Title Line 2')).toBeInTheDocument();
expect(screen.getByText('Test Description Text')).toBeInTheDocument();
});
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
const mockGetText = vi.fn().mockImplementation((key, fallback) => fallback);
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
const handleShopNavigate = vi.fn();
render(<Hero onShopNavigate={handleShopNavigate} />);
const shopBtn = screen.getByText('مشاهده محصولات');
fireEvent.click(shopBtn);
expect(handleShopNavigate).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import Tooltip from '../Tooltip';
import { useSettingsStore } from '../../store/settingsStore';
vi.mock('../../store/settingsStore', () => ({
useSettingsStore: vi.fn(),
}));
describe('Tooltip', () => {
it('should render children correctly', () => {
vi.mocked(useSettingsStore).mockReturnValue({});
render(
<Tooltip termKey="mussel">
<span>Mussel Term</span>
</Tooltip>
);
expect(screen.getByText('Mussel Term')).toBeInTheDocument();
});
it('should display tooltip definition on hover', async () => {
const mockTerms = {
mussel: {
key: 'mussel',
term: 'Green Mussel',
definition: 'Scientific definition of NZ green mussel',
wikiId: 'green-mussel',
},
};
vi.mocked(useSettingsStore).mockReturnValue(mockTerms);
render(
<Tooltip termKey="mussel">
<span>Hover Me</span>
</Tooltip>
);
const trigger = screen.getByText('Hover Me');
fireEvent.mouseEnter(trigger);
expect(screen.getByText('Green Mussel')).toBeInTheDocument();
expect(screen.getByText('Scientific definition of NZ green mussel')).toBeInTheDocument();
fireEvent.mouseLeave(trigger);
// Tooltip should exit (though animations might delay actual removal, under standard RTL fireEvent it updates state immediately)
});
});

View File

@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useCartStore } from '../cartStore';
import { orderService } from '../../services/orderService';
vi.mock('../../services/orderService', () => ({
orderService: {
createOrder: vi.fn(),
},
}));
const mockProduct = {
id: 'prod-1',
artNo: 'art-1',
name: 'Canhydrox GAG',
scientificTagline: 'Joint helper',
description: 'Joints',
price: '۱,۰۰۰ تومان',
priceValue: 1000,
category: 'joints',
unit: 'tablet',
packageSize: 100,
main_ingredients: [],
dosage_logic: '1',
benefits: '',
symptoms: [],
suitableFor: 'سگ' as any,
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
analysis: {},
feedingAdvice: '',
specialist: null as any,
image: '',
};
describe('cartStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useCartStore.setState({
items: [],
isSubscribed: false,
charityDonation: 0,
coupon: null,
orders: [],
});
});
it('should add item to cart', () => {
const store = useCartStore.getState();
store.addItem(mockProduct, 2);
const state = useCartStore.getState();
expect(state.items).toHaveLength(1);
expect(state.items[0].product.id).toBe('prod-1');
expect(state.items[0].quantity).toBe(2);
});
it('should increment quantity if item already in cart', () => {
const store = useCartStore.getState();
store.addItem(mockProduct, 2);
store.addItem(mockProduct, 3);
const state = useCartStore.getState();
expect(state.items).toHaveLength(1);
expect(state.items[0].quantity).toBe(5);
});
it('should remove item from cart', () => {
const store = useCartStore.getState();
store.addItem(mockProduct, 2);
store.removeItem('prod-1');
const state = useCartStore.getState();
expect(state.items).toHaveLength(0);
});
it('should update quantity', () => {
const store = useCartStore.getState();
store.addItem(mockProduct, 2);
store.updateQuantity('prod-1', 4);
const state = useCartStore.getState();
expect(state.items[0].quantity).toBe(4);
});
it('should remove item if update quantity is <= 0', () => {
const store = useCartStore.getState();
store.addItem(mockProduct, 2);
store.updateQuantity('prod-1', 0);
const state = useCartStore.getState();
expect(state.items).toHaveLength(0);
});
it('should apply discount coupon', () => {
const store = useCartStore.getState();
const success = store.applyCoupon('CANINA2024');
expect(success).toBe(true);
const state = useCartStore.getState();
expect(state.coupon).toEqual({ code: 'CANINA2024', discount: 0.1 });
});
it('should calculate subtotal, discount and total', () => {
const store = useCartStore.getState();
store.addItem(mockProduct, 2); // 2 * 1000 = 2000
store.applyCoupon('CANINA2024'); // 10% discount
expect(useCartStore.getState().getSubtotal()).toBe(2000);
expect(useCartStore.getState().getDiscount()).toBe(200);
expect(useCartStore.getState().getTotal()).toBe(1800);
});
it('should create order successfully via orderService', async () => {
const mockBackendOrder = {
id: 'backend-order-id',
createdAt: '2026-05-26T18:00:00.000Z',
totalAmount: '2000.00',
charityDonation: '0.00',
status: 'processing',
trackingNumber: 'TRK-12345',
};
vi.mocked(orderService.createOrder).mockResolvedValue(mockBackendOrder);
const store = useCartStore.getState();
store.addItem(mockProduct, 2);
const orderId = await store.addOrder({
items: store.items,
total: 2000,
charityDonation: 0,
petId: 'pet-id',
});
expect(orderService.createOrder).toHaveBeenCalled();
expect(orderId).toBe('backend-order-id');
const state = useCartStore.getState();
expect(state.orders).toHaveLength(1);
expect(state.orders[0].id).toBe('backend-order-id');
expect(state.orders[0].total).toBe(2000);
});
});

View File

@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useSettingsStore } from '../settingsStore';
import api from '../../services/api';
vi.mock('../../services/api', () => ({
default: {
get: vi.fn(),
},
}));
describe('settingsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
texts: {},
scientificTerms: {},
isLoading: false,
});
});
it('should return fallback if key is not found', () => {
const text = useSettingsStore.getState().getText('non-existent', 'Fallback Text');
expect(text).toBe('Fallback Text');
});
it('should return value if key is found', () => {
useSettingsStore.setState({
texts: { 'test-key': 'Real Value' },
});
const text = useSettingsStore.getState().getText('test-key', 'Fallback Text');
expect(text).toBe('Real Value');
});
it('should fetch settings successfully and map array to object', async () => {
const mockUiTexts = [
{ key: 'hero_title', value: 'Welcome to Canina' },
{ key: 'hero_desc', value: 'Best supplements' },
];
const mockTerms = [
{ key: 'mussel', term: 'Mussel', definition: 'NZ Green Mussel', wikiId: 'general' },
];
vi.mocked(api.get).mockImplementation((url: string) => {
if (url === '/settings/ui-texts') {
return Promise.resolve({ data: mockUiTexts });
}
if (url === '/settings/scientific-terms') {
return Promise.resolve({ data: mockTerms });
}
return Promise.reject(new Error('Unknown URL'));
});
const store = useSettingsStore.getState();
await store.fetchSettings();
const state = useSettingsStore.getState();
expect(state.texts['hero_title']).toBe('Welcome to Canina');
expect(state.texts['hero_desc']).toBe('Best supplements');
expect(state.scientificTerms['mussel']).toEqual(mockTerms[0]);
expect(state.isLoading).toBe(false);
});
});

View File

@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useUserStore } from '../userStore';
import { authService } from '../../services/authService';
import api from '../../services/api';
vi.mock('../../services/authService', () => ({
authService: {
updateProfile: vi.fn(),
logout: vi.fn(),
getProfile: vi.fn(),
},
}));
vi.mock('../../services/api', () => ({
default: {
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
describe('userStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useUserStore.setState({
role: 'User_Guest',
isLoggedIn: false,
profile: {
firstName: '',
lastName: '',
email: '',
mobile: '',
walletBalance: 0,
charityDonationTotal: 0,
addresses: [],
transactions: [],
},
});
});
it('should logout correctly', () => {
useUserStore.setState({
role: 'User_PetOwner',
isLoggedIn: true,
profile: {
firstName: 'Ali',
lastName: 'Ahmadi',
email: 'ali@example.com',
mobile: '0912',
walletBalance: 100,
charityDonationTotal: 10,
addresses: [],
transactions: [],
},
});
useUserStore.getState().logout();
expect(authService.logout).toHaveBeenCalled();
const state = useUserStore.getState();
expect(state.isLoggedIn).toBe(false);
expect(state.role).toBe('User_Guest');
expect(state.profile.firstName).toBe('');
});
it('should fetch profile successfully', async () => {
const mockProfileData = {
firstName: 'Ali',
lastName: 'Ahmadi',
email: 'ali@example.com',
mobile: '09123456789',
role: 'User_PetOwner',
walletBalance: '1500.00',
charityDonationTotal: '50.00',
addresses: [],
pets: [],
walletTransactions: [],
};
vi.mocked(authService.getProfile).mockResolvedValue(mockProfileData);
const store = useUserStore.getState();
await store.fetchProfile();
expect(authService.getProfile).toHaveBeenCalled();
const state = useUserStore.getState();
expect(state.isLoggedIn).toBe(true);
expect(state.role).toBe('User_PetOwner');
expect(state.profile.firstName).toBe('Ali');
expect(state.profile.walletBalance).toBe(1500);
});
it('should update profile correctly', async () => {
const mockUpdatedData = {
firstName: 'Ali Updated',
lastName: 'Ahmadi',
email: 'ali@example.com',
mobile: '09123456789',
};
vi.mocked(authService.updateProfile).mockResolvedValue(mockUpdatedData);
const store = useUserStore.getState();
await store.updateProfile({ firstName: 'Ali Updated' });
expect(authService.updateProfile).toHaveBeenCalledWith({ firstName: 'Ali Updated' });
const state = useUserStore.getState();
expect(state.profile.firstName).toBe('Ali Updated');
});
it('should add address and call fetchProfile', async () => {
const address = {
id: 'addr-1',
title: 'Home',
receptorName: 'Ali',
phone: '0912',
province: 'Teh',
city: 'Teh',
detail: 'Det',
zipCode: '123',
isDefault: false,
};
const fetchSpy = vi.spyOn(useUserStore.getState(), 'fetchProfile').mockResolvedValue(undefined);
vi.mocked(api.post).mockResolvedValue({ data: { success: true } });
const store = useUserStore.getState();
await store.addAddress(address);
expect(api.post).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
});
});

67
src/test/setup.ts Normal file
View File

@ -0,0 +1,67 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
class LocalStorageMock {
private store: Record<string, string> = {};
clear() {
this.store = {};
}
getItem(key: string) {
return this.store[key] || null;
}
setItem(key: string, value: string) {
this.store[key] = String(value);
}
removeItem(key: string) {
delete this.store[key];
}
get length() {
return Object.keys(this.store).length;
}
key(index: number) {
return Object.keys(this.store)[index] || null;
}
}
const localStorageMock = new LocalStorageMock();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
});
// Mock IntersectionObserver for framer-motion/motion viewport triggers
class IntersectionObserverMock {
readonly root: Element | null = null;
readonly rootMargin: string = '';
readonly thresholds: ReadonlyArray<number> = [];
constructor(callback: any, options?: any) {}
observe() {}
unobserve() {}
disconnect() {}
takeRecords() { return []; }
}
Object.defineProperty(window, 'IntersectionObserver', {
value: IntersectionObserverMock,
writable: true,
});
// Mock matchMedia if needed
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

View File

@ -1,3 +1,4 @@
/// <reference types="vitest" />
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
@ -15,9 +16,19 @@ export default defineConfig(({mode}) => {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),
}, },
}, },
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
pool: 'forks',
forks: {
singleFork: true,
},
},
server: { server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var. // HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits. // Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true', hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits. // Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {}, watch: process.env.DISABLE_HMR === 'true' ? null : {},