diff --git a/Dockerfile b/Dockerfile index 127d804..b3cdb73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ COPY . . RUN npm run build # Production stage -FROM nginx:alpine +FROM nginxinc/nginx-unprivileged:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 +EXPOSE 8080 CMD ["nginx", "-g", "daemon off;"] diff --git a/backend/Dockerfile b/backend/Dockerfile index d51ee8a..e4d25e2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,11 +28,13 @@ RUN npm ci --only=production # Copy built artifacts from the builder stage COPY --from=builder /app/dist ./dist -# If we have prisma later, we need to copy generated client: -# COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma -# COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma +# Copy generated prisma client: +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client EXPOSE 3000 +USER node + # Start the application CMD ["node", "dist/main"] diff --git a/backend/package-lock.json b/backend/package-lock.json index 59d91e5..3ca9ae1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,10 +15,12 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.22.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "helmet": "^8.2.0", "ioredis": "^5.11.0", "passport": "^0.7.0", "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": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -6272,6 +6285,18 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 4c5bb25..98b6664 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,10 +26,12 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.4.4", + "@nestjs/throttler": "^6.5.0", "@prisma/client": "^5.22.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "helmet": "^8.2.0", "ioredis": "^5.11.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 6b7596e..815f38b 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -7,6 +7,25 @@ import * as vm from 'vm'; const prisma = new PrismaClient(); 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...'); const productsFilePath = path.join(__dirname, '..', '..', 'src', 'data', 'products.ts'); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b069ee4..f49bbda 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { PrismaModule } from './prisma/prisma.module'; import { ProductsModule } from './products/products.module'; import { UsersModule } from './users/users.module'; @@ -7,10 +7,41 @@ import { AuthModule } from './auth/auth.module'; import { PetsModule } from './pets/pets.module'; import { OrdersModule } from './orders/orders.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({ - imports: [PrismaModule, RedisModule, ProductsModule, UsersModule, AuthModule, PetsModule, OrdersModule, SettingsModule], - controllers: [], - providers: [], + imports: [ + PrismaModule, + 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('*'); + } +} diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..4a09782 --- /dev/null +++ b/backend/src/auth/auth.controller.spec.ts @@ -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); + service = module.get(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'); + }); +}); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 6318b36..94091ad 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -2,25 +2,92 @@ import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common'; import { AuthService } from './auth.service'; import { SendOtpDto } from './dto/send-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') +@ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: 'خطای داخلی سرور', + schema: { + example: { + success: false, + message: 'خطای ناشناخته در سرور رخ داده است', + code: 'SERVER_ERROR', + details: {} + } + } +}) export class AuthController { constructor(private readonly authService: AuthService) {} @Post('send-otp') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'ارسال کد تایید پیامکی' }) - @ApiResponse({ status: 200, description: 'کد با موفقیت ارسال شد' }) + @ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' }) + @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) { return this.authService.sendOtp(sendOtpDto); } @Post('verify-otp') @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'تایید کد پیامکی و ورود/ثبت‌نام' }) - @ApiResponse({ status: 200, description: 'ورود موفق به همراه توکن' }) + @ApiOperation({ summary: 'تایید کد پیامکی و ورود/ثبت‌نام کاربر' }) + @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) { return this.authService.verifyOtp(verifyOtpDto); } diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..3ec8cee --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -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); + prisma = module.get(PrismaService); + jwt = module.get(JwtService); + redis = module.get(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); + }); + }); +}); diff --git a/backend/src/common/metrics.controller.ts b/backend/src/common/metrics.controller.ts new file mode 100644 index 0000000..f78141b --- /dev/null +++ b/backend/src/common/metrics.controller.ts @@ -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); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index b7c2f19..1924f3a 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,14 +3,27 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.use(helmet({ + contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets + })); + 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()); const config = new DocumentBuilder() diff --git a/backend/src/orders/orders.controller.spec.ts b/backend/src/orders/orders.controller.spec.ts new file mode 100644 index 0000000..fe8d879 --- /dev/null +++ b/backend/src/orders/orders.controller.spec.ts @@ -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); + service = module.get(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'); + }); +}); diff --git a/backend/src/orders/orders.controller.ts b/backend/src/orders/orders.controller.ts index 0b1134b..95b8236 100644 --- a/backend/src/orders/orders.controller.ts +++ b/backend/src/orders/orders.controller.ts @@ -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 { CreateOrderDto } from './dto/create-order.dto'; 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() @UseGuards(JwtAuthGuard) @Controller('orders') +@ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است', + schema: { + example: { + success: false, + message: 'Unauthorized', + code: 'UNAUTHORIZED', + details: {} + } + } +}) export class OrdersController { constructor(private readonly ordersService: OrdersService) {} @Post() @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) { return this.ordersService.create(req.user.id, createOrderDto); } @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) { return this.ordersService.findAllByUser(req.user.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) { return this.ordersService.findOne(id, req.user.id); } diff --git a/backend/src/orders/orders.service.spec.ts b/backend/src/orders/orders.service.spec.ts new file mode 100644 index 0000000..16b9e33 --- /dev/null +++ b/backend/src/orders/orders.service.spec.ts @@ -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); + prisma = module.get(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); + }); + }); +}); diff --git a/backend/src/pets/pets.controller.spec.ts b/backend/src/pets/pets.controller.spec.ts new file mode 100644 index 0000000..438ea10 --- /dev/null +++ b/backend/src/pets/pets.controller.spec.ts @@ -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); + service = module.get(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); + }); +}); diff --git a/backend/src/pets/pets.controller.ts b/backend/src/pets/pets.controller.ts index 85de646..e47b535 100644 --- a/backend/src/pets/pets.controller.ts +++ b/backend/src/pets/pets.controller.ts @@ -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 { CreatePetDto } from './dto/create-pet.dto'; import { UpdatePetDto } from './dto/update-pet.dto'; 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() @UseGuards(JwtAuthGuard) @Controller('pets') +@ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است', + schema: { + example: { + success: false, + message: 'Unauthorized', + code: 'UNAUTHORIZED', + details: {} + } + } +}) export class PetsController { constructor(private readonly petsService: PetsService) {} @Post() @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) { return this.petsService.create(req.user.id, createPetDto); } @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) { return this.petsService.findAllByUser(req.user.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) { return this.petsService.findOne(id, req.user.id); } @Patch(':id') @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) { return this.petsService.update(id, req.user.id, updatePetDto); } @Delete(':id') @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) { return this.petsService.remove(id, req.user.id); } diff --git a/backend/src/pets/pets.service.spec.ts b/backend/src/pets/pets.service.spec.ts new file mode 100644 index 0000000..9a87f8a --- /dev/null +++ b/backend/src/pets/pets.service.spec.ts @@ -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); + prisma = module.get(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'); + }); +}); diff --git a/backend/src/products/products.controller.spec.ts b/backend/src/products/products.controller.spec.ts new file mode 100644 index 0000000..51b623b --- /dev/null +++ b/backend/src/products/products.controller.spec.ts @@ -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); + service = module.get(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); + }); + }); +}); diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index d6b4f97..0a53c91 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -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 { 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') +@ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: 'خطای داخلی سرور', + schema: { + example: { + success: false, + message: 'خطای داخلی سرور', + code: 'SERVER_ERROR', + details: {} + } + } +}) export class ProductsController { constructor(private readonly productsService: ProductsService) {} @Get() - @ApiOperation({ summary: 'List Products' }) - @ApiResponse({ status: 200, description: 'List of products' }) + @ApiOperation({ summary: 'لیست و فیلتر محصولات' }) + @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) { return this.productsService.findAll(query); } @Get(':id') - @ApiOperation({ summary: 'Get Product by ID' }) - @ApiResponse({ status: 200, description: 'Product details' }) + @ApiOperation({ summary: 'دریافت اطلاعات محصول با شناسه یکتا (ID)' }) + @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) { const product = await this.productsService.findOne(id); if (!product) { diff --git a/backend/src/products/products.service.spec.ts b/backend/src/products/products.service.spec.ts new file mode 100644 index 0000000..b904c81 --- /dev/null +++ b/backend/src/products/products.service.spec.ts @@ -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); + prisma = module.get(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); + }); + }); +}); diff --git a/backend/src/redis/redis.service.spec.ts b/backend/src/redis/redis.service.spec.ts new file mode 100644 index 0000000..805388b --- /dev/null +++ b/backend/src/redis/redis.service.spec.ts @@ -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); + 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'); + }); +}); diff --git a/backend/src/settings/settings.controller.spec.ts b/backend/src/settings/settings.controller.spec.ts new file mode 100644 index 0000000..65043f0 --- /dev/null +++ b/backend/src/settings/settings.controller.spec.ts @@ -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); + service = module.get(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); + }); +}); diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts index 5474034..ad33aed 100644 --- a/backend/src/settings/settings.controller.ts +++ b/backend/src/settings/settings.controller.ts @@ -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 { 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') export class SettingsController { constructor(private readonly settingsService: SettingsService) {} @Get('ui-texts') - @ApiOperation({ summary: 'دریافت تمامی متون رابط کاربری' }) - @ApiResponse({ status: 200, description: 'تمامی متون بازگردانده شدند' }) + @ApiOperation({ summary: 'دریافت تمامی متون و پیکربندی‌های رابط کاربری' }) + @ApiOkResponse({ + description: 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها', + schema: { + example: { + hero_badge: "تخصص دارویی از آلمان", + hero_title: "تخصص آلمانی در خدمت سلامت پت‌های خانگی", + hero_desc: "بیش از ۴۰ سال تجربه نوآورانه..." + } + } + }) getUiTexts() { return this.settingsService.getUiTexts(); } @@ -18,15 +27,39 @@ export class SettingsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Patch('ui-texts/:key') - @ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری' }) - @ApiResponse({ status: 200, description: 'متن با موفقیت به‌روزرسانی شد' }) + @ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری (نیازمند توکن)' }) + @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) { return this.settingsService.updateUiText(key, value); } @Get('scientific-terms') - @ApiOperation({ summary: 'دریافت تمامی اصطلاحات علمی' }) - @ApiResponse({ status: 200, description: 'تمامی اصطلاحات علمی بازگردانده شدند' }) + @ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژه‌نامه علمی' }) + @ApiOkResponse({ + description: 'لیست کامل اصطلاحات علمی به همراه تعاریف و شناسه‌ها', + schema: { + example: [ + { + key: 'green-mussel', + term: 'صدف لب‌سبز (Perna Canaliculus)', + definition: 'این صدف بومی سواحل بکر نیوزیلند است...', + wikiId: 'general' + } + ] + } + }) getScientificTerms() { return this.settingsService.getScientificTerms(); } @@ -34,8 +67,22 @@ export class SettingsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Put('scientific-terms/:key') - @ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' }) - @ApiResponse({ status: 200, description: 'اصطلاح علمی ثبت یا ویرایش شد' }) + @ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' }) + @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) { return this.settingsService.upsertScientificTerm(key, data); } @@ -43,8 +90,20 @@ export class SettingsController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Delete('scientific-terms/:key') - @ApiOperation({ summary: 'حذف یک اصطلاح علمی' }) - @ApiResponse({ status: 200, description: 'اصطلاح علمی حذف شد' }) + @ApiOperation({ summary: 'حذف یک اصطلاح علمی (نیازمند توکن)' }) + @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) { return this.settingsService.deleteScientificTerm(key); } diff --git a/backend/src/settings/settings.service.spec.ts b/backend/src/settings/settings.service.spec.ts new file mode 100644 index 0000000..9e5f1b1 --- /dev/null +++ b/backend/src/settings/settings.service.spec.ts @@ -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); + prisma = module.get(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'); + }); +}); diff --git a/backend/src/users/users.controller.spec.ts b/backend/src/users/users.controller.spec.ts new file mode 100644 index 0000000..b1118db --- /dev/null +++ b/backend/src/users/users.controller.spec.ts @@ -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); + service = module.get(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); + }); +}); diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 0895c1f..ca34c15 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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 { 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 { AddressDto } from './dto/address.dto'; -@ApiTags('Users') +@ApiTags('Users - مدیریت کاربران و آدرس‌ها') @ApiBearerAuth() @Controller('users') +@ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است', + schema: { + example: { + success: false, + message: 'Unauthorized', + code: 'UNAUTHORIZED', + details: {} + } + } +}) export class UsersController { constructor(private readonly usersService: UsersService) {} @UseGuards(JwtAuthGuard) @Get('profile') @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) { return this.usersService.findById(req.user.id); } @@ -21,7 +52,34 @@ export class UsersController { @UseGuards(JwtAuthGuard) @Patch('profile') @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) { return this.usersService.update(req.user.id, updateProfileDto); } @@ -29,7 +87,24 @@ export class UsersController { @UseGuards(JwtAuthGuard) @Post('addresses') @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) { return this.usersService.addAddress(req.user.id, addressDto); } @@ -37,7 +112,24 @@ export class UsersController { @UseGuards(JwtAuthGuard) @Patch('addresses/:addressId') @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( @Req() req: any, @Param('addressId') addressId: string, @@ -49,7 +141,15 @@ export class UsersController { @UseGuards(JwtAuthGuard) @Delete('addresses/:addressId') @ApiOperation({ summary: 'حذف آدرس کاربر' }) - @ApiResponse({ status: 200, description: 'آدرس با موفقیت حذف شد' }) + @ApiOkResponse({ + description: 'آدرس با موفقیت حذف شد', + schema: { + example: { + success: true, + message: 'آدرس با موفقیت حذف شد' + } + } + }) deleteAddress(@Req() req: any, @Param('addressId') addressId: string) { return this.usersService.deleteAddress(req.user.id, addressId); } @@ -57,7 +157,15 @@ export class UsersController { @UseGuards(JwtAuthGuard) @Patch('addresses/:addressId/default') @ApiOperation({ summary: 'انتخاب آدرس به عنوان پیش‌فرض' }) - @ApiResponse({ status: 200, description: 'آدرس به عنوان پیش‌فرض ثبت شد' }) + @ApiOkResponse({ + description: 'آدرس به عنوان پیش‌فرض ثبت شد', + schema: { + example: { + success: true, + message: 'آدرس پیش‌فرض با موفقیت تغییر کرد' + } + } + }) setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) { return this.usersService.setDefaultAddress(req.user.id, addressId); } diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts new file mode 100644 index 0000000..20c9e39 --- /dev/null +++ b/backend/src/users/users.service.spec.ts @@ -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); + prisma = module.get(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); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 2a56ec3..ec7c12c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,9 +45,31 @@ services: container_name: canina_frontend restart: always ports: - - "3001:80" + - "3001:8080" depends_on: - 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: postgres_data: diff --git a/nginx.conf b/nginx.conf index c3d2d98..324eb5c 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,9 +1,17 @@ server { - listen 80; + listen 8080; server_name localhost; root /usr/share/nginx/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 location / { try_files $uri $uri/ /index.html; diff --git a/package-lock.json b/package-lock.json index d7af4af..0c13ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,18 +23,81 @@ "zustand": "^5.0.13" }, "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/node": "^22.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", + "jsdom": "^29.1.1", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.3" + "vite": "^6.2.3", + "vitest": "^4.1.7" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -253,6 +316,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -298,6 +371,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -714,6 +940,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@google/genai": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", @@ -1177,6 +1421,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -1434,6 +1685,104 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1486,6 +1835,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1496,6 +1856,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1644,6 +2011,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1666,12 +2146,57 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1784,6 +2309,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -1929,6 +2464,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1983,6 +2528,27 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1999,6 +2565,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2016,6 +2596,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2034,6 +2621,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -2053,6 +2650,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -2122,6 +2727,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2140,6 +2758,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2223,6 +2848,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2232,6 +2867,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2659,6 +3304,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2704,6 +3362,16 @@ "node": ">=0.10.0" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2719,6 +3387,13 @@ "node": ">= 0.10" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2734,6 +3409,57 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3061,6 +3787,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3079,6 +3816,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -3139,6 +3883,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/motion": { "version": "12.38.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", @@ -3269,6 +4023,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3294,6 +4059,19 @@ "node": ">=8" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3309,6 +4087,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3362,6 +4147,22 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/protobufjs": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", @@ -3408,6 +4209,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -3468,6 +4279,14 @@ "react": "^19.2.6" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3477,6 +4296,30 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -3556,6 +4399,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3703,6 +4559,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -3722,6 +4585,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3731,6 +4601,33 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -3750,6 +4647,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", + "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -3766,6 +4680,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.0.tgz", + "integrity": "sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.0" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.0.tgz", + "integrity": "sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3775,6 +4719,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4285,6 +5255,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4422,6 +5402,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -4431,6 +5514,58 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", @@ -4452,6 +5587,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index d30b91f..8faa5f0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist server.js", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@google/genai": "^1.29.0", @@ -26,15 +28,20 @@ "zustand": "^5.0.13" }, "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/node": "^22.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", + "jsdom": "^29.1.1", "tailwindcss": "^4.1.14", "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.3" + "vite": "^6.2.3", + "vitest": "^4.1.7" } } diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..93b2410 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: 'nest-backend' + metrics_path: '/api/metrics' + static_configs: + - targets: ['backend:3000'] diff --git a/src/components/B2BPortal.tsx b/src/components/B2BPortal.tsx index f6e490f..c5dbeca 100644 --- a/src/components/B2BPortal.tsx +++ b/src/components/B2BPortal.tsx @@ -1,6 +1,7 @@ -import React, { useState } from "react"; -import { PRODUCTS, Product } from "../data/products"; +import React, { useState, useEffect } from "react"; +import { Product } from "../data/products"; import { useCartStore } from "../store/cartStore"; +import { productService } from "../services/productService"; import { motion, AnimatePresence } from "motion/react"; import { Building2, @@ -20,12 +21,17 @@ interface QuickOrderItem { } export default function B2BPortal({ onClose }: { onClose: () => void }) { + const [products, setProducts] = useState([]); const [quantities, setQuantities] = useState>({}); const [searchQuery, setSearchQuery] = useState(""); const { addItem } = useCartStore(); 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.artNo.includes(searchQuery) ); @@ -39,7 +45,7 @@ export default function B2BPortal({ onClose }: { onClose: () => void }) { Object.entries(quantities).forEach(([id, qty]) => { const numQty = qty as number; if (numQty > 0) { - const product = PRODUCTS.find(p => p.id === id); + const product = products.find(p => p.id === id); if (product) { addItem(product, numQty); } diff --git a/src/components/BlogPage.tsx b/src/components/BlogPage.tsx index 5dbba32..2241d4f 100644 --- a/src/components/BlogPage.tsx +++ b/src/components/BlogPage.tsx @@ -1,7 +1,8 @@ import React from "react"; import { motion } from "motion/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 = [ { @@ -42,6 +43,11 @@ const BLOG_POSTS = [ export default function BlogPage({ onBack, onProductClick }: { onBack: () => void, onProductClick: (p: Product) => void }) { const [selectedPost, setSelectedPost] = React.useState(null); + const [products, setProducts] = React.useState([]); + + React.useEffect(() => { + productService.getProducts().then(data => setProducts(data)); + }, []); if (selectedPost) { return ( @@ -113,7 +119,7 @@ export default function BlogPage({ onBack, onProductClick }: { onBack: () => voi

محصولات پیشنهادی

- {PRODUCTS.slice(4, 7).map(p => ( + {products.slice(4, 7).map(p => (
onProductClick(p)} @@ -316,7 +322,7 @@ export default function BlogPage({ onBack, onProductClick }: { onBack: () => voi
- {PRODUCTS.slice(0, 2).map((p) => ( + {products.slice(0, 2).map((p) => (
onProductClick(p)} diff --git a/src/components/CartDrawer.tsx b/src/components/CartDrawer.tsx index 98ac5b8..91b01b8 100644 --- a/src/components/CartDrawer.tsx +++ b/src/components/CartDrawer.tsx @@ -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 { motion, AnimatePresence } from "motion/react"; import { toPersian, cn } from "../lib/utils"; 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 { toast } from "sonner"; @@ -32,10 +33,18 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate // Smart Cross-sell Logic const hasCanhydrox = items.some(i => i.product.id === 'canhydrox-gag'); const hasLachsOl = items.some(i => i.product.id === 'lachs-ol'); - - const suggestedProduct = (!hasLachsOl && hasCanhydrox) - ? PRODUCTS.find(p => p.id === 'lachs-ol') - : null; + const [suggestedProduct, setSuggestedProduct] = useState(null); + + useEffect(() => { + 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 ( @@ -103,6 +112,7 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate @@ -110,6 +120,7 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate diff --git a/src/components/CheckoutPage.tsx b/src/components/CheckoutPage.tsx index 015ca4a..d54f99c 100644 --- a/src/components/CheckoutPage.tsx +++ b/src/components/CheckoutPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { motion } from "motion/react"; import { ChevronRight, @@ -19,7 +19,8 @@ import { toPersian, cn } from "../lib/utils"; import { useCartStore } from "../store/cartStore"; import { usePetStore } from "../store/usePetStore"; import { useUserStore } from "../store/userStore"; -import { PRODUCTS } from "../data/products"; +import { Product } from "../data/products"; +import { productService } from "../services/productService"; import { toast } from "sonner"; 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 [loading, setLoading] = useState(false); const [useRoundUp, setUseRoundUp] = useState(false); + const [dbProducts, setDbProducts] = useState([]); + + useEffect(() => { + productService.getProducts().then(data => setDbProducts(data)); + }, []); const subtotal = getSubtotal(); const roundedAmount = Math.ceil(subtotal / 10000) * 10000; @@ -329,7 +335,7 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi {items.map(item => { const activePet = usePetStore.getState().getActivePet(); // 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 days = Math.floor(fullProduct.packageSize / (dose.quantity || 1)); diff --git a/src/components/IngredientWiki.tsx b/src/components/IngredientWiki.tsx index 6db713f..592c811 100644 --- a/src/components/IngredientWiki.tsx +++ b/src/components/IngredientWiki.tsx @@ -1,12 +1,18 @@ -import { useMemo } from "react"; -import { INGREDIENTS_WIKI, PRODUCTS, Product } from "../data/products"; +import { useMemo, useState, useEffect } from "react"; +import { INGREDIENTS_WIKI, Product } from "../data/products"; import { motion } from "motion/react"; import { FlaskConical, CheckCircle2, ChevronRight, ChevronLeft, Beaker } from "lucide-react"; import { useSettingsStore } from "../store/settingsStore"; +import { productService } from "../services/productService"; export default function IngredientWiki({ onProductClick, onBack }: { onProductClick: (p: Product) => void, onBack?: () => void }) { const texts = useSettingsStore(state => state.texts); - + const [products, setProducts] = useState([]); + + useEffect(() => { + productService.getProducts().then(data => setProducts(data)); + }, []); + const ingredientsWiki = useMemo(() => { const jsonStr = texts['ingredients_wiki']; if (jsonStr) { @@ -62,9 +68,9 @@ export default function IngredientWiki({ onProductClick, onBack }: { onProductCl
{ingredientsWiki.map((ing, idx) => { - 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.id.replace('-', ' '))) + 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.id.replace('-', ' '))) ); return ( diff --git a/src/components/PetProfile.tsx b/src/components/PetProfile.tsx index 65cae87..53923ee 100644 --- a/src/components/PetProfile.tsx +++ b/src/components/PetProfile.tsx @@ -1,5 +1,6 @@ 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 { PetProfileSkeleton } from "./Skeleton"; import { @@ -43,6 +44,13 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso const { orders } = useCartStore(); const activePet = getActivePet(); const [isLoading, setIsLoading] = useState(true); + const [products, setProducts] = useState([]); + + useEffect(() => { + productService.getProducts() + .then(setProducts) + .catch(err => console.error("Error fetching products in PetProfile:", err)); + }, []); useEffect(() => { const timer = setTimeout(() => setIsLoading(false), 800); @@ -151,50 +159,50 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso }; const recommendedProducts = useMemo(() => { - if (!activePet) return []; + if (!activePet || products.length === 0) return []; let picks: { product: Product, reason?: string }[] = []; // Priority: Medical Conditions 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 (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 (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 (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 (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: "تامین فوری انرژی و تحریک اشتها" }); } // Biological factors 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: "محافظت از قلب پت‌های مسن" }); } // Fill defaults if needed 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 })); } return picks; - }, [activePet]); + }, [activePet, products]); const handleNext = () => setStep(s => s + 1); const handleBackStep = () => setStep(s => s - 1); @@ -716,7 +724,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
) : ( 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; const percentage = (consumption.remaining / (consumption.packageSize || 1)) * 100; diff --git a/src/components/ProductPage.tsx b/src/components/ProductPage.tsx index 7e30b58..8571fed 100644 --- a/src/components/ProductPage.tsx +++ b/src/components/ProductPage.tsx @@ -40,7 +40,8 @@ const ICON_MAP: Record = { Users }; 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 { useSettingsStore } from "../store/settingsStore"; 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 [showRefillModal, setShowRefillModal] = useState(false); const [itemQuantity, setItemQuantity] = useState(1); + const [allProducts, setAllProducts] = useState([]); + + useEffect(() => { + productService.getProducts() + .then(setAllProducts) + .catch(err => console.error("Error fetching products in ProductPage:", err)); + }, []); const { pets, getActivePet } = usePetStore(); const activePet = getActivePet(); 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 const fullProduct = useMemo(() => { - return PRODUCTS.find(p => p.id === product.id) || product; - }, [product]); + return allProducts.find(p => p.id === product.id) || product; + }, [product, allProducts]); const syncActivePet = useMemo(() => { if (activePet) { @@ -490,7 +498,7 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
{product.relatedProducts.map(relId => { - const relProduct = PRODUCTS.find(p => p.id === relId); + const relProduct = allProducts.find(p => p.id === relId); if (!relProduct) return null; return ( void; onBack: () => void }) { const { getActivePet } = usePetStore(); const activePet = getActivePet(); + const [products, setProducts] = useState([]); - 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.description.toLowerCase().includes(query.toLowerCase()) || p.main_ingredients.some(ing => ing.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 (
diff --git a/src/components/VideosPage.tsx b/src/components/VideosPage.tsx index 27c02c8..7c4ff00 100644 --- a/src/components/VideosPage.tsx +++ b/src/components/VideosPage.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { motion, AnimatePresence } from "motion/react"; import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react"; -import { Product, PRODUCTS } from "../data/products"; + const ALL_VIDEOS = [ { diff --git a/src/components/__tests__/CartDrawer.test.tsx b/src/components/__tests__/CartDrawer.test.tsx new file mode 100644 index 0000000..1f871c8 --- /dev/null +++ b/src/components/__tests__/CartDrawer.test.tsx @@ -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(); + + 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(); + + 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(); + + 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); + }); +}); diff --git a/src/components/__tests__/FeaturedProducts.test.tsx b/src/components/__tests__/FeaturedProducts.test.tsx new file mode 100644 index 0000000..af164b1 --- /dev/null +++ b/src/components/__tests__/FeaturedProducts.test.tsx @@ -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(); + // 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(); + + 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(); + + 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(); + + const navBtn = screen.getByText('مشاهده تمامی محصولات'); + fireEvent.click(navBtn); + expect(handleShopNavigate).toHaveBeenCalled(); + }); +}); diff --git a/src/components/__tests__/Footer.test.tsx b/src/components/__tests__/Footer.test.tsx new file mode 100644 index 0000000..ce2b365 --- /dev/null +++ b/src/components/__tests__/Footer.test.tsx @@ -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(