feat: complete testing, security, dynamic content, and devops overhaul
This commit is contained in:
parent
467532713c
commit
40ff169dc2
@ -7,8 +7,8 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:alpine
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 8080
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@ -28,11 +28,13 @@ RUN npm ci --only=production
|
|||||||
|
|
||||||
# Copy built artifacts from the builder stage
|
# Copy built artifacts from the builder stage
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
# If we have prisma later, we need to copy generated client:
|
# Copy generated prisma client:
|
||||||
# COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
# COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
CMD ["node", "dist/main"]
|
CMD ["node", "dist/main"]
|
||||||
|
|||||||
25
backend/package-lock.json
generated
25
backend/package-lock.json
generated
@ -15,10 +15,12 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.4.4",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"helmet": "^8.2.0",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
@ -2537,6 +2539,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/throttler": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
@ -6272,6 +6285,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/EvanHahn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
|||||||
@ -26,10 +26,12 @@
|
|||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.4.4",
|
"@nestjs/swagger": "^11.4.4",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
"helmet": "^8.2.0",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@ -7,6 +7,25 @@ import * as vm from 'vm';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
console.log('Wiping existing database records...');
|
||||||
|
await prisma.reminderCompletion.deleteMany();
|
||||||
|
await prisma.reminder.deleteMany();
|
||||||
|
await prisma.healthLog.deleteMany();
|
||||||
|
await prisma.petMedicalCondition.deleteMany();
|
||||||
|
await prisma.pet.deleteMany();
|
||||||
|
await prisma.orderItem.deleteMany();
|
||||||
|
await prisma.order.deleteMany();
|
||||||
|
await prisma.walletTransaction.deleteMany();
|
||||||
|
await prisma.userAddress.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
await prisma.productIngredient.deleteMany();
|
||||||
|
await prisma.productSymptom.deleteMany();
|
||||||
|
await prisma.product.deleteMany();
|
||||||
|
await prisma.coupon.deleteMany();
|
||||||
|
await prisma.uiText.deleteMany();
|
||||||
|
await prisma.scientificTerm.deleteMany();
|
||||||
|
console.log('Database successfully wiped.');
|
||||||
|
|
||||||
console.log('Seeding database with products...');
|
console.log('Seeding database with products...');
|
||||||
|
|
||||||
const productsFilePath = path.join(__dirname, '..', '..', 'src', 'data', 'products.ts');
|
const productsFilePath = path.join(__dirname, '..', '..', 'src', 'data', 'products.ts');
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
@ -7,10 +7,41 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { PetsModule } from './pets/pets.module';
|
import { PetsModule } from './pets/pets.module';
|
||||||
import { OrdersModule } from './orders/orders.module';
|
import { OrdersModule } from './orders/orders.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { MetricsController } from './common/metrics.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RedisModule, ProductsModule, UsersModule, AuthModule, PetsModule, OrdersModule, SettingsModule],
|
imports: [
|
||||||
controllers: [],
|
PrismaModule,
|
||||||
providers: [],
|
RedisModule,
|
||||||
|
ProductsModule,
|
||||||
|
UsersModule,
|
||||||
|
AuthModule,
|
||||||
|
PetsModule,
|
||||||
|
OrdersModule,
|
||||||
|
SettingsModule,
|
||||||
|
ThrottlerModule.forRoot([{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 100,
|
||||||
|
}]),
|
||||||
|
],
|
||||||
|
controllers: [MetricsController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer
|
||||||
|
.apply((req: any, res: any, next: () => void) => {
|
||||||
|
MetricsController.incrementRequestCount();
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
.exclude('metrics')
|
||||||
|
.forRoutes('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
47
backend/src/auth/auth.controller.spec.ts
Normal file
47
backend/src/auth/auth.controller.spec.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthController', () => {
|
||||||
|
let controller: AuthController;
|
||||||
|
let service: AuthService;
|
||||||
|
|
||||||
|
const mockAuthService = {
|
||||||
|
sendOtp: jest.fn().mockResolvedValue({ success: true, message: 'کد تایید ارسال شد' }),
|
||||||
|
verifyOtp: jest.fn().mockResolvedValue({ success: true, data: { accessToken: 'token' } }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call sendOtp on service', async () => {
|
||||||
|
const dto = { phoneNumber: '09123456789' };
|
||||||
|
const result = await controller.sendOtp(dto);
|
||||||
|
expect(service.sendOtp).toHaveBeenCalledWith(dto);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call verifyOtp on service', async () => {
|
||||||
|
const dto = { phoneNumber: '09123456789', code: '12345' };
|
||||||
|
const result = await controller.verifyOtp(dto);
|
||||||
|
expect(service.verifyOtp).toHaveBeenCalledWith(dto);
|
||||||
|
expect(result.data.accessToken).toBe('token');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,25 +2,92 @@ import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { SendOtpDto } from './dto/send-otp.dto';
|
import { SendOtpDto } from './dto/send-otp.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiBadRequestResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth - احراز هویت')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
description: 'خطای داخلی سرور',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'خطای ناشناخته در سرور رخ داده است',
|
||||||
|
code: 'SERVER_ERROR',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
@Post('send-otp')
|
@Post('send-otp')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'ارسال کد تایید پیامکی' })
|
@ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' })
|
||||||
@ApiResponse({ status: 200, description: 'کد با موفقیت ارسال شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'کد با موفقیت ارسال شد (کد تایید در پاسخ بازگردانده میشود)',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
message: 'کد تایید ارسال شد',
|
||||||
|
code: '12345'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({
|
||||||
|
description: 'فرمت شماره تلفن همراه نامعتبر است',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'شماره موبایل نامعتبر است',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
details: {
|
||||||
|
message: ['شماره موبایل نامعتبر است']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
sendOtp(@Body() sendOtpDto: SendOtpDto) {
|
sendOtp(@Body() sendOtpDto: SendOtpDto) {
|
||||||
return this.authService.sendOtp(sendOtpDto);
|
return this.authService.sendOtp(sendOtpDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('verify-otp')
|
@Post('verify-otp')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'تایید کد پیامکی و ورود/ثبتنام' })
|
@ApiOperation({ summary: 'تایید کد پیامکی و ورود/ثبتنام کاربر' })
|
||||||
@ApiResponse({ status: 200, description: 'ورود موفق به همراه توکن' })
|
@ApiOkResponse({
|
||||||
|
description: 'ورود موفق به همراه توکن دسترسی JWT و اطلاعات کاربر',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
firstName: 'کاربر',
|
||||||
|
lastName: 'جدید',
|
||||||
|
email: '09123456789@temp.local',
|
||||||
|
mobile: '09123456789',
|
||||||
|
role: 'User_PetOwner',
|
||||||
|
walletBalance: '0.00',
|
||||||
|
charityDonationTotal: '0.00',
|
||||||
|
createdAt: '2026-05-26T15:20:00.000Z',
|
||||||
|
updatedAt: '2026-05-26T15:20:00.000Z'
|
||||||
|
},
|
||||||
|
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({
|
||||||
|
description: 'کد تایید اشتباه یا منقضی شده است',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'کد تایید اشتباه است',
|
||||||
|
code: 'OTP_INVALID',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
|
verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
|
||||||
return this.authService.verifyOtp(verifyOtpDto);
|
return this.authService.verifyOtp(verifyOtpDto);
|
||||||
}
|
}
|
||||||
|
|||||||
109
backend/src/auth/auth.service.spec.ts
Normal file
109
backend/src/auth/auth.service.spec.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let service: AuthService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let jwt: JwtService;
|
||||||
|
let redis: RedisService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockJwt = {
|
||||||
|
sign: jest.fn().mockReturnValue('mock-jwt-token'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRedis = {
|
||||||
|
set: jest.fn(),
|
||||||
|
get: jest.fn(),
|
||||||
|
del: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: JwtService, useValue: mockJwt },
|
||||||
|
{ provide: RedisService, useValue: mockRedis },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AuthService>(AuthService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
jwt = module.get<JwtService>(JwtService);
|
||||||
|
redis = module.get<RedisService>(RedisService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendOtp', () => {
|
||||||
|
it('should generate a 5 digit OTP and save it in Redis', async () => {
|
||||||
|
const result = await service.sendOtp({ phoneNumber: '09123456789' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.code).toHaveLength(5);
|
||||||
|
expect(redis.set).toHaveBeenCalledWith(
|
||||||
|
'otp:09123456789',
|
||||||
|
result.code,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifyOtp', () => {
|
||||||
|
it('should throw BadRequestException if OTP is expired/not found', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.verifyOtp({ phoneNumber: '09123456789', code: '12345' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException if OTP is incorrect', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue('54321');
|
||||||
|
await expect(
|
||||||
|
service.verifyOtp({ phoneNumber: '09123456789', code: '12345' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete OTP, find or create user and sign JWT', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue('12345');
|
||||||
|
const mockUser = { id: 'user-id', mobile: '09123456789' };
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
const result = await service.verifyOtp({ phoneNumber: '09123456789', code: '12345' });
|
||||||
|
|
||||||
|
expect(redis.del).toHaveBeenCalledWith('otp:09123456789');
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { mobile: '09123456789' } });
|
||||||
|
expect(jwt.sign).toHaveBeenCalledWith({ sub: 'user-id', phoneNumber: '09123456789' });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.accessToken).toBe('mock-jwt-token');
|
||||||
|
expect(result.data.user).toEqual(mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create new user if user does not exist', async () => {
|
||||||
|
mockRedis.get.mockResolvedValue('12345');
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||||
|
const newUser = { id: 'new-user-id', mobile: '09123456789' };
|
||||||
|
mockPrisma.user.create.mockResolvedValue(newUser);
|
||||||
|
|
||||||
|
const result = await service.verifyOtp({ phoneNumber: '09123456789', code: '12345' });
|
||||||
|
expect(prisma.user.create).toHaveBeenCalled();
|
||||||
|
expect(result.data.user).toEqual(newUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
backend/src/common/metrics.controller.ts
Normal file
67
backend/src/common/metrics.controller.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Controller, Get, Res } from '@nestjs/common';
|
||||||
|
import { ApiExcludeController } from '@nestjs/swagger';
|
||||||
|
import { PrismaService } from '../../src/prisma/prisma.service';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@ApiExcludeController()
|
||||||
|
@Controller('metrics')
|
||||||
|
export class MetricsController {
|
||||||
|
private static requestCount = 0;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
public static incrementRequestCount() {
|
||||||
|
this.requestCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getMetrics(@Res() res: Response) {
|
||||||
|
const memory = process.memoryUsage();
|
||||||
|
const cpu = process.cpuUsage();
|
||||||
|
|
||||||
|
let dbStatus = 1;
|
||||||
|
try {
|
||||||
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
|
} catch (e) {
|
||||||
|
dbStatus = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptime = process.uptime();
|
||||||
|
|
||||||
|
const responseText = `# HELP node_memory_rss_bytes Resident set size in bytes.
|
||||||
|
# TYPE node_memory_rss_bytes gauge
|
||||||
|
node_memory_rss_bytes ${memory.rss}
|
||||||
|
|
||||||
|
# HELP node_memory_heap_used_bytes Heap used in bytes.
|
||||||
|
# TYPE node_memory_heap_used_bytes gauge
|
||||||
|
node_memory_heap_used_bytes ${memory.heapUsed}
|
||||||
|
|
||||||
|
# HELP node_memory_heap_total_bytes Heap total in bytes.
|
||||||
|
# TYPE node_memory_heap_total_bytes gauge
|
||||||
|
node_memory_heap_total_bytes ${memory.heapTotal}
|
||||||
|
|
||||||
|
# HELP node_cpu_user_time_microseconds CPU user time in microseconds.
|
||||||
|
# TYPE node_cpu_user_time_microseconds counter
|
||||||
|
node_cpu_user_time_microseconds ${cpu.user}
|
||||||
|
|
||||||
|
# HELP node_cpu_system_time_microseconds CPU system time in microseconds.
|
||||||
|
# TYPE node_cpu_system_time_microseconds counter
|
||||||
|
node_cpu_system_time_microseconds ${cpu.system}
|
||||||
|
|
||||||
|
# HELP node_app_uptime_seconds Application uptime in seconds.
|
||||||
|
# TYPE node_app_uptime_seconds gauge
|
||||||
|
node_app_uptime_seconds ${uptime}
|
||||||
|
|
||||||
|
# HELP canina_db_connected Database connection status (1 = connected, 0 = disconnected).
|
||||||
|
# TYPE canina_db_connected gauge
|
||||||
|
canina_db_connected ${dbStatus}
|
||||||
|
|
||||||
|
# HELP canina_http_requests_total Total HTTP requests processed.
|
||||||
|
# TYPE canina_http_requests_total counter
|
||||||
|
canina_http_requests_total ${MetricsController.requestCount}
|
||||||
|
`;
|
||||||
|
|
||||||
|
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
||||||
|
res.end(responseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,14 +3,27 @@ import { AppModule } from './app.module';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets
|
||||||
|
}));
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
app.enableCors();
|
|
||||||
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(new ValidationPipe({
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}));
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
|
|||||||
56
backend/src/orders/orders.controller.spec.ts
Normal file
56
backend/src/orders/orders.controller.spec.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { OrdersController } from './orders.controller';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
describe('OrdersController', () => {
|
||||||
|
let controller: OrdersController;
|
||||||
|
let service: OrdersService;
|
||||||
|
|
||||||
|
const mockOrdersService = {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }),
|
||||||
|
findAllByUser: jest.fn().mockResolvedValue([{ id: 'order-id', totalAmount: 1000 }]),
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [OrdersController],
|
||||||
|
providers: [
|
||||||
|
{ provide: OrdersService, useValue: mockOrdersService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<OrdersController>(OrdersController);
|
||||||
|
service = module.get<OrdersService>(OrdersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create order', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
|
||||||
|
const result = await controller.create(req, dto);
|
||||||
|
expect(service.create).toHaveBeenCalledWith('user-id', dto);
|
||||||
|
expect(result.id).toBe('order-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list orders of user', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.findAll(req);
|
||||||
|
expect(service.findAllByUser).toHaveBeenCalledWith('user-id');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find one order', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.findOne(req, 'order-id');
|
||||||
|
expect(service.findOne).toHaveBeenCalledWith('order-id', 'user-id');
|
||||||
|
expect(result.id).toBe('order-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,30 +1,120 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Req } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, UseGuards, Req, HttpStatus } from '@nestjs/common';
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
import { CreateOrderDto } from './dto/create-order.dto';
|
import { CreateOrderDto } from './dto/create-order.dto';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Orders')
|
@ApiTags('Orders - مدیریت سفارشها')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('orders')
|
@Controller('orders')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.UNAUTHORIZED,
|
||||||
|
description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(private readonly ordersService: OrdersService) {}
|
constructor(private readonly ordersService: OrdersService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'ثبت سفارش جدید' })
|
@ApiOperation({ summary: 'ثبت سفارش جدید' })
|
||||||
|
@ApiCreatedResponse({
|
||||||
|
description: 'سفارش با موفقیت ثبت شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
couponId: null,
|
||||||
|
totalAmount: '3500000.00',
|
||||||
|
charityDonation: '10000.00',
|
||||||
|
status: 'processing',
|
||||||
|
trackingNumber: null,
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({
|
||||||
|
description: 'اعتبارسنجی اقلام سبد خرید با خطا مواجه شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'سبد خرید نمیتواند خالی باشد',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
|
create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
|
||||||
return this.ordersService.create(req.user.id, createOrderDto);
|
return this.ordersService.create(req.user.id, createOrderDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'لیست سفارشهای کاربر' })
|
@ApiOperation({ summary: 'لیست سفارشهای کاربر فعلی' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'آرایهای از سفارشهای ثبت شده کاربر',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
totalAmount: '3500000.00',
|
||||||
|
charityDonation: '10000.00',
|
||||||
|
status: 'processing',
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
findAll(@Req() req: any) {
|
findAll(@Req() req: any) {
|
||||||
return this.ordersService.findAllByUser(req.user.id);
|
return this.ordersService.findAllByUser(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'جزئیات یک سفارش' })
|
@ApiOperation({ summary: 'دریافت جزئیات یک سفارش خاص' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'اطلاعات کامل سفارش مشخص شده به همراه اقلام سفارش',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
totalAmount: '3500000.00',
|
||||||
|
charityDonation: '10000.00',
|
||||||
|
status: 'processing',
|
||||||
|
trackingNumber: 'TRK-987654321',
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z',
|
||||||
|
orderItems: [
|
||||||
|
{
|
||||||
|
id: 'item-1',
|
||||||
|
quantity: 2,
|
||||||
|
product: {
|
||||||
|
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||||
|
name: 'Canhydrox GAG (کنهیدروکس)',
|
||||||
|
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
|
||||||
|
imageUrl: 'https://example.com/canhydrox.png'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({
|
||||||
|
description: 'سفارش یافت نشد یا متعلق به کاربر فعلی نیست',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Order not found',
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
findOne(@Req() req: any, @Param('id') id: string) {
|
findOne(@Req() req: any, @Param('id') id: string) {
|
||||||
return this.ordersService.findOne(id, req.user.id);
|
return this.ordersService.findOne(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|||||||
103
backend/src/orders/orders.service.spec.ts
Normal file
103
backend/src/orders/orders.service.spec.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('OrdersService', () => {
|
||||||
|
let service: OrdersService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
product: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
OrdersService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<OrdersService>(OrdersService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should throw NotFoundException if product does not exist', async () => {
|
||||||
|
mockPrisma.product.findUnique.mockResolvedValue(null);
|
||||||
|
const dto = { items: [{ productId: 'invalid-prod', quantity: 2 }] };
|
||||||
|
await expect(service.create('user-id', dto)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException if items are empty', async () => {
|
||||||
|
const dto = { items: [] };
|
||||||
|
await expect(service.create('user-id', dto)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should successfully create order and sum amounts', async () => {
|
||||||
|
const prod = { id: 'prod-1', priceValue: 1000 };
|
||||||
|
mockPrisma.product.findUnique.mockResolvedValue(prod);
|
||||||
|
mockPrisma.order.create.mockResolvedValue({ id: 'order-1', totalAmount: 2000 });
|
||||||
|
|
||||||
|
const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
|
||||||
|
const result = await service.create('user-id', dto);
|
||||||
|
|
||||||
|
expect(prisma.product.findUnique).toHaveBeenCalledWith({ where: { id: 'prod-1' } });
|
||||||
|
expect(prisma.order.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: 'user-id',
|
||||||
|
totalAmount: 2000,
|
||||||
|
status: 'processing',
|
||||||
|
orderItems: {
|
||||||
|
create: [{ productId: 'prod-1', quantity: 2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
orderItems: {
|
||||||
|
include: { product: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.id).toBe('order-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAllByUser', () => {
|
||||||
|
it('should find all orders of a user', async () => {
|
||||||
|
mockPrisma.order.findMany.mockResolvedValue([{ id: 'order-1' }]);
|
||||||
|
const result = await service.findAllByUser('user-id');
|
||||||
|
expect(prisma.order.findMany).toHaveBeenCalled();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should throw NotFoundException if order does not exist', async () => {
|
||||||
|
mockPrisma.order.findFirst.mockResolvedValue(null);
|
||||||
|
await expect(service.findOne('order-id', 'user-id')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return order if found', async () => {
|
||||||
|
const order = { id: 'order-1', userId: 'user-id' };
|
||||||
|
mockPrisma.order.findFirst.mockResolvedValue(order);
|
||||||
|
const result = await service.findOne('order-1', 'user-id');
|
||||||
|
expect(result).toEqual(order);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
backend/src/pets/pets.controller.spec.ts
Normal file
73
backend/src/pets/pets.controller.spec.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { PetsController } from './pets.controller';
|
||||||
|
import { PetsService } from './pets.service';
|
||||||
|
|
||||||
|
describe('PetsController', () => {
|
||||||
|
let controller: PetsController;
|
||||||
|
let service: PetsService;
|
||||||
|
|
||||||
|
const mockPetsService = {
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||||
|
findAllByUser: jest.fn().mockResolvedValue([{ id: 'pet-id', name: 'Buddy' }]),
|
||||||
|
findOne: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }),
|
||||||
|
remove: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [PetsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: PetsService, useValue: mockPetsService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<PetsController>(PetsController);
|
||||||
|
service = module.get<PetsService>(PetsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create pet', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const dto = { name: 'Buddy', type: 'Dog' };
|
||||||
|
const result = await controller.create(req, dto);
|
||||||
|
expect(service.create).toHaveBeenCalledWith('user-id', dto);
|
||||||
|
expect(result.id).toBe('pet-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should findAll pets', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.findAll(req);
|
||||||
|
expect(service.findAllByUser).toHaveBeenCalledWith('user-id');
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should findOne pet', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.findOne(req, 'pet-id');
|
||||||
|
expect(service.findOne).toHaveBeenCalledWith('pet-id', 'user-id');
|
||||||
|
expect(result.id).toBe('pet-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update pet', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const dto = { name: 'Buddy New' };
|
||||||
|
const result = await controller.update(req, 'pet-id', dto);
|
||||||
|
expect(service.update).toHaveBeenCalledWith('pet-id', 'user-id', dto);
|
||||||
|
expect(result.name).toBe('Buddy New');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove pet', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.remove(req, 'pet-id');
|
||||||
|
expect(service.remove).toHaveBeenCalledWith('pet-id', 'user-id');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,43 +1,178 @@
|
|||||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req, HttpStatus } from '@nestjs/common';
|
||||||
import { PetsService } from './pets.service';
|
import { PetsService } from './pets.service';
|
||||||
import { CreatePetDto } from './dto/create-pet.dto';
|
import { CreatePetDto } from './dto/create-pet.dto';
|
||||||
import { UpdatePetDto } from './dto/update-pet.dto';
|
import { UpdatePetDto } from './dto/update-pet.dto';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Pets')
|
@ApiTags('Pets - مدیریت حیوانات خانگی')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pets')
|
@Controller('pets')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.UNAUTHORIZED,
|
||||||
|
description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
export class PetsController {
|
export class PetsController {
|
||||||
constructor(private readonly petsService: PetsService) {}
|
constructor(private readonly petsService: PetsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'ثبت حیوان خانگی جدید' })
|
@ApiOperation({ summary: 'ثبت حیوان خانگی جدید' })
|
||||||
|
@ApiCreatedResponse({
|
||||||
|
description: 'حیوان خانگی جدید با موفقیت ثبت شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
name: 'بادی',
|
||||||
|
type: 'سگ',
|
||||||
|
breed: 'ژرمن شپرد',
|
||||||
|
age: 3,
|
||||||
|
weight: '25.50',
|
||||||
|
activityLevel: 'متوسط',
|
||||||
|
imageUrl: null,
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({
|
||||||
|
description: 'خطا در صحتسنجی فیلدهای ورودی',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'نوع حیوان خانگی اجباری است',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
create(@Req() req: any, @Body() createPetDto: CreatePetDto) {
|
create(@Req() req: any, @Body() createPetDto: CreatePetDto) {
|
||||||
return this.petsService.create(req.user.id, createPetDto);
|
return this.petsService.create(req.user.id, createPetDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'لیست حیوانات خانگی کاربر' })
|
@ApiOperation({ summary: 'لیست حیوانات خانگی کاربر فعلی' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'آرایهای از حیوانات خانگی ثبت شده کاربر',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
name: 'بادی',
|
||||||
|
type: 'سگ',
|
||||||
|
breed: 'ژرمن شپرد',
|
||||||
|
age: 3,
|
||||||
|
weight: '25.50',
|
||||||
|
activityLevel: 'متوسط',
|
||||||
|
imageUrl: null,
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
findAll(@Req() req: any) {
|
findAll(@Req() req: any) {
|
||||||
return this.petsService.findAllByUser(req.user.id);
|
return this.petsService.findAllByUser(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'دریافت جزئیات یک حیوان خانگی' })
|
@ApiOperation({ summary: 'دریافت جزئیات کامل یک حیوان خانگی' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'اطلاعات کامل حیوان خانگی مشخص شده با ID',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
name: 'بادی',
|
||||||
|
type: 'سگ',
|
||||||
|
breed: 'ژرمن شپرد',
|
||||||
|
age: 3,
|
||||||
|
weight: '25.50',
|
||||||
|
activityLevel: 'متوسط',
|
||||||
|
imageUrl: null,
|
||||||
|
medicalConditions: [],
|
||||||
|
reminders: [],
|
||||||
|
healthLogs: [],
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({
|
||||||
|
description: 'حیوان خانگی پیدا نشد یا متعلق به کاربر جاری نیست',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Pet not found or unauthorized',
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
findOne(@Req() req: any, @Param('id') id: string) {
|
findOne(@Req() req: any, @Param('id') id: string) {
|
||||||
return this.petsService.findOne(id, req.user.id);
|
return this.petsService.findOne(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: 'بروزرسانی اطلاعات حیوان خانگی' })
|
@ApiOperation({ summary: 'بروزرسانی اطلاعات حیوان خانگی' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'اطلاعات حیوان خانگی با موفقیت بهروزرسانی شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
|
||||||
|
name: 'بادی قهرمان',
|
||||||
|
type: 'سگ',
|
||||||
|
breed: 'ژرمن شپرد',
|
||||||
|
age: 4,
|
||||||
|
weight: '26.00',
|
||||||
|
activityLevel: 'زیاد'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({
|
||||||
|
description: 'حیوان خانگی یافت نشد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Pet not found',
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
update(@Req() req: any, @Param('id') id: string, @Body() updatePetDto: UpdatePetDto) {
|
update(@Req() req: any, @Param('id') id: string, @Body() updatePetDto: UpdatePetDto) {
|
||||||
return this.petsService.update(id, req.user.id, updatePetDto);
|
return this.petsService.update(id, req.user.id, updatePetDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@ApiOperation({ summary: 'حذف حیوان خانگی' })
|
@ApiOperation({ summary: 'حذف حیوان خانگی' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'حیوان خانگی با موفقیت حذف شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
message: 'حیوان خانگی با موفقیت حذف شد'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({
|
||||||
|
description: 'حیوان خانگی یافت نشد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Pet not found',
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
remove(@Req() req: any, @Param('id') id: string) {
|
remove(@Req() req: any, @Param('id') id: string) {
|
||||||
return this.petsService.remove(id, req.user.id);
|
return this.petsService.remove(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|||||||
93
backend/src/pets/pets.service.spec.ts
Normal file
93
backend/src/pets/pets.service.spec.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { PetsService } from './pets.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('PetsService', () => {
|
||||||
|
let service: PetsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
pet: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
PetsService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<PetsService>(PetsService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create pet', async () => {
|
||||||
|
const dto = { name: 'Buddy', type: 'Dog', weight: 12.5 };
|
||||||
|
mockPrisma.pet.create.mockResolvedValue({ id: 'pet-id', ...dto });
|
||||||
|
|
||||||
|
const result = await service.create('user-id', dto);
|
||||||
|
expect(prisma.pet.create).toHaveBeenCalled();
|
||||||
|
expect(result.name).toBe('Buddy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find all pets by user', async () => {
|
||||||
|
mockPrisma.pet.findMany.mockResolvedValue([{ id: 'pet-1' }]);
|
||||||
|
const result = await service.findAllByUser('user-id');
|
||||||
|
expect(prisma.pet.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-id' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should throw NotFoundException if pet not found', async () => {
|
||||||
|
mockPrisma.pet.findFirst.mockResolvedValue(null);
|
||||||
|
await expect(service.findOne('pet-id', 'user-id')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return pet if found', async () => {
|
||||||
|
const pet = { id: 'pet-id', userId: 'user-id' };
|
||||||
|
mockPrisma.pet.findFirst.mockResolvedValue(pet);
|
||||||
|
const result = await service.findOne('pet-id', 'user-id');
|
||||||
|
expect(result).toEqual(pet);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update pet', async () => {
|
||||||
|
const pet = { id: 'pet-id', userId: 'user-id' };
|
||||||
|
mockPrisma.pet.findFirst.mockResolvedValue(pet);
|
||||||
|
const dto = { name: 'Buddy New', type: 'Dog' };
|
||||||
|
mockPrisma.pet.update.mockResolvedValue({ ...pet, ...dto });
|
||||||
|
|
||||||
|
const result = await service.update('pet-id', 'user-id', dto);
|
||||||
|
expect(prisma.pet.update).toHaveBeenCalled();
|
||||||
|
expect(result.name).toBe('Buddy New');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove pet', async () => {
|
||||||
|
const pet = { id: 'pet-id', userId: 'user-id' };
|
||||||
|
mockPrisma.pet.findFirst.mockResolvedValue(pet);
|
||||||
|
mockPrisma.pet.delete.mockResolvedValue(pet);
|
||||||
|
|
||||||
|
const result = await service.remove('pet-id', 'user-id');
|
||||||
|
expect(prisma.pet.delete).toHaveBeenCalled();
|
||||||
|
expect(result.id).toBe('pet-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
56
backend/src/products/products.controller.spec.ts
Normal file
56
backend/src/products/products.controller.spec.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ProductsController } from './products.controller';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
describe('ProductsController', () => {
|
||||||
|
let controller: ProductsController;
|
||||||
|
let service: ProductsService;
|
||||||
|
|
||||||
|
const mockProductsService = {
|
||||||
|
findAll: jest.fn().mockResolvedValue([{ id: 'prod-id', name: 'Product 1' }]),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [ProductsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: ProductsService, useValue: mockProductsService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<ProductsController>(ProductsController);
|
||||||
|
service = module.get<ProductsService>(ProductsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list products', async () => {
|
||||||
|
const query = { category: 'joints' };
|
||||||
|
const result = await controller.findAll(query);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith(query);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should throw NotFoundException if product not found', async () => {
|
||||||
|
mockProductsService.findOne.mockResolvedValue(null);
|
||||||
|
await expect(controller.findOne('invalid-id')).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return product details if found', async () => {
|
||||||
|
const prod = { id: 'prod-id', name: 'Product 1' };
|
||||||
|
mockProductsService.findOne.mockResolvedValue(prod);
|
||||||
|
const result = await controller.findOne('prod-id');
|
||||||
|
expect(service.findOne).toHaveBeenCalledWith('prod-id');
|
||||||
|
expect(result).toEqual(prod);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,23 +1,100 @@
|
|||||||
import { Controller, Get, Query, Param, NotFoundException } from '@nestjs/common';
|
import { Controller, Get, Query, Param, NotFoundException, HttpStatus } from '@nestjs/common';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
import { GetProductsDto } from './dto/get-products.dto';
|
import { GetProductsDto } from './dto/get-products.dto';
|
||||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Products')
|
@ApiTags('Products - مدیریت محصولات دارویی')
|
||||||
@Controller('products')
|
@Controller('products')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
description: 'خطای داخلی سرور',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'خطای داخلی سرور',
|
||||||
|
code: 'SERVER_ERROR',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
export class ProductsController {
|
export class ProductsController {
|
||||||
constructor(private readonly productsService: ProductsService) {}
|
constructor(private readonly productsService: ProductsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List Products' })
|
@ApiOperation({ summary: 'لیست و فیلتر محصولات' })
|
||||||
@ApiResponse({ status: 200, description: 'List of products' })
|
@ApiOkResponse({
|
||||||
|
description: 'لیست محصولات متناسب با فیلترها (دسته، پت و جستجو)',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||||
|
artNo: 'canhydrox-gag',
|
||||||
|
name: 'Canhydrox GAG (کنهیدروکس)',
|
||||||
|
scientificTagline: 'برای تقویت مفاصل و استخوانها',
|
||||||
|
description: 'کنهیدروکس محصولی بینظیر برای مفاصل و سیستم حرکتی سگها...',
|
||||||
|
shortDescription: 'تقویت مفاصل و غضروفها',
|
||||||
|
category: 'سیستم حرکتی و مفاصل',
|
||||||
|
categorySlug: 'joints',
|
||||||
|
priceValue: '1750000.00',
|
||||||
|
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
|
||||||
|
unit: 'عدد قرص',
|
||||||
|
packageSize: '120.00',
|
||||||
|
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
|
||||||
|
suitableFor: 'سگ',
|
||||||
|
imageUrl: 'https://example.com/canhydrox.png',
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z',
|
||||||
|
ingredients: [],
|
||||||
|
symptoms: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
findAll(@Query() query: GetProductsDto) {
|
findAll(@Query() query: GetProductsDto) {
|
||||||
return this.productsService.findAll(query);
|
return this.productsService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get Product by ID' })
|
@ApiOperation({ summary: 'دریافت اطلاعات محصول با شناسه یکتا (ID)' })
|
||||||
@ApiResponse({ status: 200, description: 'Product details' })
|
@ApiOkResponse({
|
||||||
|
description: 'اطلاعات کامل محصول شامل مواد تشکیلدهنده و علائم مرتبط',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||||
|
artNo: 'canhydrox-gag',
|
||||||
|
name: 'Canhydrox GAG (کنهیدروکس)',
|
||||||
|
scientificTagline: 'برای تقویت مفاصل و استخوانها',
|
||||||
|
description: 'کنهیدروکس محصولی بینظیر برای مفاصل...',
|
||||||
|
shortDescription: 'تقویت مفاصل و غضروفها',
|
||||||
|
category: 'سیستم حرکتی و مفاصل',
|
||||||
|
categorySlug: 'joints',
|
||||||
|
priceValue: '1750000.00',
|
||||||
|
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
|
||||||
|
unit: 'عدد قرص',
|
||||||
|
packageSize: '120.00',
|
||||||
|
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
|
||||||
|
suitableFor: 'سگ',
|
||||||
|
imageUrl: 'https://example.com/canhydrox.png',
|
||||||
|
createdAt: '2026-05-26T18:10:00.000Z',
|
||||||
|
ingredients: [
|
||||||
|
{ productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', ingredient: 'صدف لبسبز' }
|
||||||
|
],
|
||||||
|
symptoms: [
|
||||||
|
{ productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', symptom: 'لنگیدن' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({
|
||||||
|
description: 'محصول با شناسه ارسال شده پیدا نشد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found',
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
async findOne(@Param('id') id: string) {
|
async findOne(@Param('id') id: string) {
|
||||||
const product = await this.productsService.findOne(id);
|
const product = await this.productsService.findOne(id);
|
||||||
if (!product) {
|
if (!product) {
|
||||||
|
|||||||
75
backend/src/products/products.service.spec.ts
Normal file
75
backend/src/products/products.service.spec.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('ProductsService', () => {
|
||||||
|
let service: ProductsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
product: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
ProductsService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<ProductsService>(ProductsService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should query products with correct filters', async () => {
|
||||||
|
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1' }]);
|
||||||
|
const filters = { category: 'joints', petType: 'سگ', query: 'can' };
|
||||||
|
const result = await service.findAll(filters);
|
||||||
|
|
||||||
|
expect(prisma.product.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
categorySlug: 'joints',
|
||||||
|
suitableFor: { in: ['سگ', 'هر دو'] },
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: 'can', mode: 'insensitive' } },
|
||||||
|
{ description: { contains: 'can', mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
ingredients: true,
|
||||||
|
symptoms: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should find product by id', async () => {
|
||||||
|
const prod = { id: 'prod-1' };
|
||||||
|
mockPrisma.product.findUnique.mockResolvedValue(prod);
|
||||||
|
const result = await service.findOne('prod-1');
|
||||||
|
expect(prisma.product.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'prod-1' },
|
||||||
|
include: {
|
||||||
|
ingredients: true,
|
||||||
|
symptoms: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual(prod);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
backend/src/redis/redis.service.spec.ts
Normal file
56
backend/src/redis/redis.service.spec.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
jest.mock('ioredis', () => {
|
||||||
|
return jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
set: jest.fn(),
|
||||||
|
get: jest.fn().mockResolvedValue('mock-value'),
|
||||||
|
del: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RedisService', () => {
|
||||||
|
let service: RedisService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [RedisService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<RedisService>(RedisService);
|
||||||
|
service.onModuleInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
service.onModuleDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call set with or without ttl', async () => {
|
||||||
|
const setSpy = jest.spyOn((service as any).client, 'set');
|
||||||
|
await service.set('key', 'val');
|
||||||
|
expect(setSpy).toHaveBeenCalledWith('key', 'val');
|
||||||
|
|
||||||
|
await service.set('key', 'val', 60);
|
||||||
|
expect(setSpy).toHaveBeenCalledWith('key', 'val', 'EX', 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call get', async () => {
|
||||||
|
const getSpy = jest.spyOn((service as any).client, 'get');
|
||||||
|
const result = await service.get('key');
|
||||||
|
expect(getSpy).toHaveBeenCalledWith('key');
|
||||||
|
expect(result).toBe('mock-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call del', async () => {
|
||||||
|
const delSpy = jest.spyOn((service as any).client, 'del');
|
||||||
|
await service.del('key');
|
||||||
|
expect(delSpy).toHaveBeenCalledWith('key');
|
||||||
|
});
|
||||||
|
});
|
||||||
67
backend/src/settings/settings.controller.spec.ts
Normal file
67
backend/src/settings/settings.controller.spec.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
describe('SettingsController', () => {
|
||||||
|
let controller: SettingsController;
|
||||||
|
let service: SettingsService;
|
||||||
|
|
||||||
|
const mockSettingsService = {
|
||||||
|
getUiTexts: jest.fn().mockResolvedValue([{ key: 'k', value: 'v' }]),
|
||||||
|
updateUiText: jest.fn().mockResolvedValue({ key: 'k', value: 'v' }),
|
||||||
|
getScientificTerms: jest.fn().mockResolvedValue([{ key: 'k' }]),
|
||||||
|
upsertScientificTerm: jest.fn().mockResolvedValue({ key: 'k' }),
|
||||||
|
deleteScientificTerm: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [
|
||||||
|
{ provide: SettingsService, useValue: mockSettingsService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<SettingsController>(SettingsController);
|
||||||
|
service = module.get<SettingsService>(SettingsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should getUiTexts', async () => {
|
||||||
|
const result = await controller.getUiTexts();
|
||||||
|
expect(service.getUiTexts).toHaveBeenCalled();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should updateUiText', async () => {
|
||||||
|
const result = await controller.updateUiText('k', 'v');
|
||||||
|
expect(service.updateUiText).toHaveBeenCalledWith('k', 'v');
|
||||||
|
expect(result.key).toBe('k');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should getScientificTerms', async () => {
|
||||||
|
const result = await controller.getScientificTerms();
|
||||||
|
expect(service.getScientificTerms).toHaveBeenCalled();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upsertScientificTerm', async () => {
|
||||||
|
const data = { term: 't', definition: 'd' };
|
||||||
|
const result = await controller.upsertScientificTerm('k', data);
|
||||||
|
expect(service.upsertScientificTerm).toHaveBeenCalledWith('k', data);
|
||||||
|
expect(result.key).toBe('k');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deleteScientificTerm', async () => {
|
||||||
|
const result = await controller.deleteScientificTerm('k');
|
||||||
|
expect(service.deleteScientificTerm).toHaveBeenCalledWith('k');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,16 +1,25 @@
|
|||||||
import { Controller, Get, Patch, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Patch, Put, Delete, Body, Param, UseGuards, HttpStatus } from '@nestjs/common';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Settings')
|
@ApiTags('Settings - تنظیمات متون پویا و واژهنامه علمی')
|
||||||
@Controller('settings')
|
@Controller('settings')
|
||||||
export class SettingsController {
|
export class SettingsController {
|
||||||
constructor(private readonly settingsService: SettingsService) {}
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
@Get('ui-texts')
|
@Get('ui-texts')
|
||||||
@ApiOperation({ summary: 'دریافت تمامی متون رابط کاربری' })
|
@ApiOperation({ summary: 'دریافت تمامی متون و پیکربندیهای رابط کاربری' })
|
||||||
@ApiResponse({ status: 200, description: 'تمامی متون بازگردانده شدند' })
|
@ApiOkResponse({
|
||||||
|
description: 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسبهای دکمهها',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
hero_badge: "تخصص دارویی از آلمان",
|
||||||
|
hero_title: "تخصص آلمانی در خدمت سلامت پتهای خانگی",
|
||||||
|
hero_desc: "بیش از ۴۰ سال تجربه نوآورانه..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
getUiTexts() {
|
getUiTexts() {
|
||||||
return this.settingsService.getUiTexts();
|
return this.settingsService.getUiTexts();
|
||||||
}
|
}
|
||||||
@ -18,15 +27,39 @@ export class SettingsController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Patch('ui-texts/:key')
|
@Patch('ui-texts/:key')
|
||||||
@ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری' })
|
@ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری (نیازمند توکن)' })
|
||||||
@ApiResponse({ status: 200, description: 'متن با موفقیت بهروزرسانی شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'متن با موفقیت بهروزرسانی شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
key: 'hero_badge',
|
||||||
|
value: 'تخصص دارویی ممتاز از آلمان'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'عدم دسترسی به دلیل عدم احراز هویت',
|
||||||
|
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
|
||||||
|
})
|
||||||
updateUiText(@Param('key') key: string, @Body('value') value: string) {
|
updateUiText(@Param('key') key: string, @Body('value') value: string) {
|
||||||
return this.settingsService.updateUiText(key, value);
|
return this.settingsService.updateUiText(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('scientific-terms')
|
@Get('scientific-terms')
|
||||||
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات علمی' })
|
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژهنامه علمی' })
|
||||||
@ApiResponse({ status: 200, description: 'تمامی اصطلاحات علمی بازگردانده شدند' })
|
@ApiOkResponse({
|
||||||
|
description: 'لیست کامل اصطلاحات علمی به همراه تعاریف و شناسهها',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
key: 'green-mussel',
|
||||||
|
term: 'صدف لبسبز (Perna Canaliculus)',
|
||||||
|
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
|
||||||
|
wikiId: 'general'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
getScientificTerms() {
|
getScientificTerms() {
|
||||||
return this.settingsService.getScientificTerms();
|
return this.settingsService.getScientificTerms();
|
||||||
}
|
}
|
||||||
@ -34,8 +67,22 @@ export class SettingsController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Put('scientific-terms/:key')
|
@Put('scientific-terms/:key')
|
||||||
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' })
|
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' })
|
||||||
@ApiResponse({ status: 200, description: 'اصطلاح علمی ثبت یا ویرایش شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'اصطلاح علمی ثبت یا ویرایش شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
key: 'green-mussel',
|
||||||
|
term: 'صدف لبسبز اصل نیوزیلند',
|
||||||
|
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
|
||||||
|
wikiId: 'general'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'عدم دسترسی',
|
||||||
|
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
|
||||||
|
})
|
||||||
upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
|
upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
|
||||||
return this.settingsService.upsertScientificTerm(key, data);
|
return this.settingsService.upsertScientificTerm(key, data);
|
||||||
}
|
}
|
||||||
@ -43,8 +90,20 @@ export class SettingsController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Delete('scientific-terms/:key')
|
@Delete('scientific-terms/:key')
|
||||||
@ApiOperation({ summary: 'حذف یک اصطلاح علمی' })
|
@ApiOperation({ summary: 'حذف یک اصطلاح علمی (نیازمند توکن)' })
|
||||||
@ApiResponse({ status: 200, description: 'اصطلاح علمی حذف شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'اصطلاح علمی حذف شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
message: 'Scientific term successfully deleted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiUnauthorizedResponse({
|
||||||
|
description: 'عدم دسترسی',
|
||||||
|
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
|
||||||
|
})
|
||||||
deleteScientificTerm(@Param('key') key: string) {
|
deleteScientificTerm(@Param('key') key: string) {
|
||||||
return this.settingsService.deleteScientificTerm(key);
|
return this.settingsService.deleteScientificTerm(key);
|
||||||
}
|
}
|
||||||
|
|||||||
86
backend/src/settings/settings.service.spec.ts
Normal file
86
backend/src/settings/settings.service.spec.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('SettingsService', () => {
|
||||||
|
let service: SettingsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
uiText: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
upsert: jest.fn(),
|
||||||
|
},
|
||||||
|
scientificTerm: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
upsert: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
SettingsService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<SettingsService>(SettingsService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should getUiTexts', async () => {
|
||||||
|
mockPrisma.uiText.findMany.mockResolvedValue([{ key: 'k', value: 'v' }]);
|
||||||
|
const result = await service.getUiTexts();
|
||||||
|
expect(prisma.uiText.findMany).toHaveBeenCalled();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should updateUiText', async () => {
|
||||||
|
mockPrisma.uiText.upsert.mockResolvedValue({ key: 'k', value: 'v2' });
|
||||||
|
const result = await service.updateUiText('k', 'v2');
|
||||||
|
expect(prisma.uiText.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { key: 'k' },
|
||||||
|
update: { value: 'v2' },
|
||||||
|
create: { key: 'k', value: 'v2' },
|
||||||
|
});
|
||||||
|
expect(result.value).toBe('v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should getScientificTerms', async () => {
|
||||||
|
mockPrisma.scientificTerm.findMany.mockResolvedValue([{ key: 'k' }]);
|
||||||
|
const result = await service.getScientificTerms();
|
||||||
|
expect(prisma.scientificTerm.findMany).toHaveBeenCalled();
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upsertScientificTerm', async () => {
|
||||||
|
mockPrisma.scientificTerm.upsert.mockResolvedValue({ key: 'k', term: 't' });
|
||||||
|
const data = { term: 't', definition: 'd', wikiId: 'w' };
|
||||||
|
const result = await service.upsertScientificTerm('k', data);
|
||||||
|
expect(prisma.scientificTerm.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { key: 'k' },
|
||||||
|
update: { term: 't', definition: 'd', wikiId: 'w' },
|
||||||
|
create: { key: 'k', term: 't', definition: 'd', wikiId: 'w' },
|
||||||
|
});
|
||||||
|
expect(result.term).toBe('t');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deleteScientificTerm', async () => {
|
||||||
|
mockPrisma.scientificTerm.delete.mockResolvedValue({ key: 'k' });
|
||||||
|
const result = await service.deleteScientificTerm('k');
|
||||||
|
expect(prisma.scientificTerm.delete).toHaveBeenCalledWith({
|
||||||
|
where: { key: 'k' },
|
||||||
|
});
|
||||||
|
expect(result.key).toBe('k');
|
||||||
|
});
|
||||||
|
});
|
||||||
98
backend/src/users/users.controller.spec.ts
Normal file
98
backend/src/users/users.controller.spec.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
describe('UsersController', () => {
|
||||||
|
let controller: UsersController;
|
||||||
|
let service: UsersService;
|
||||||
|
|
||||||
|
const mockUsersService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ id: 'user-id', firstName: 'Test' }),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'user-id', firstName: 'Updated' }),
|
||||||
|
addAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Home' }),
|
||||||
|
updateAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Work' }),
|
||||||
|
deleteAddress: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
setDefaultAddress: jest.fn().mockResolvedValue({ success: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [
|
||||||
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<UsersController>(UsersController);
|
||||||
|
service = module.get<UsersService>(UsersService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should getProfile', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.getProfile(req);
|
||||||
|
expect(service.findById).toHaveBeenCalledWith('user-id');
|
||||||
|
expect(result.id).toBe('user-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should updateProfile', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const dto = { firstName: 'Updated' };
|
||||||
|
const result = await controller.updateProfile(req, dto);
|
||||||
|
expect(service.update).toHaveBeenCalledWith('user-id', dto);
|
||||||
|
expect(result.firstName).toBe('Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should addAddress', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const dto = {
|
||||||
|
title: 'Home',
|
||||||
|
receptorName: 'Ali',
|
||||||
|
phone: '0912',
|
||||||
|
province: 'Teh',
|
||||||
|
city: 'Teh',
|
||||||
|
detail: 'Det',
|
||||||
|
zipCode: '123',
|
||||||
|
};
|
||||||
|
const result = await controller.addAddress(req, dto);
|
||||||
|
expect(service.addAddress).toHaveBeenCalledWith('user-id', dto);
|
||||||
|
expect(result.id).toBe('addr-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should updateAddress', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const dto = {
|
||||||
|
title: 'Work',
|
||||||
|
receptorName: 'Ali',
|
||||||
|
phone: '0912',
|
||||||
|
province: 'Teh',
|
||||||
|
city: 'Teh',
|
||||||
|
detail: 'Det',
|
||||||
|
zipCode: '123',
|
||||||
|
};
|
||||||
|
const result = await controller.updateAddress(req, 'addr-id', dto);
|
||||||
|
expect(service.updateAddress).toHaveBeenCalledWith('user-id', 'addr-id', dto);
|
||||||
|
expect(result.title).toBe('Work');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deleteAddress', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.deleteAddress(req, 'addr-id');
|
||||||
|
expect(service.deleteAddress).toHaveBeenCalledWith('user-id', 'addr-id');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should setDefaultAddress', async () => {
|
||||||
|
const req = { user: { id: 'user-id' } };
|
||||||
|
const result = await controller.setDefaultAddress(req, 'addr-id');
|
||||||
|
expect(service.setDefaultAddress).toHaveBeenCalledWith('user-id', 'addr-id');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,19 +1,50 @@
|
|||||||
import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards, Req } from '@nestjs/common';
|
import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards, Req, HttpStatus } from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse } from '@nestjs/swagger';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { AddressDto } from './dto/address.dto';
|
import { AddressDto } from './dto/address.dto';
|
||||||
|
|
||||||
@ApiTags('Users')
|
@ApiTags('Users - مدیریت کاربران و آدرسها')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.UNAUTHORIZED,
|
||||||
|
description: 'عدم احراز هویت - توکن معتبر نیست یا ارسال نشده است',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
details: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
@ApiOperation({ summary: 'دریافت پروفایل کاربر فعلی' })
|
@ApiOperation({ summary: 'دریافت پروفایل کاربر فعلی' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'مشخصات کامل کاربر به همراه آدرسها و اطلاعات حیوانات خانگی',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
firstName: 'کاربر',
|
||||||
|
lastName: 'جدید',
|
||||||
|
email: '09123456789@temp.local',
|
||||||
|
mobile: '09123456789',
|
||||||
|
role: 'User_PetOwner',
|
||||||
|
walletBalance: '1500000.00',
|
||||||
|
charityDonationTotal: '25000.00',
|
||||||
|
addresses: [],
|
||||||
|
pets: [],
|
||||||
|
createdAt: '2026-05-26T15:20:00.000Z',
|
||||||
|
updatedAt: '2026-05-26T15:20:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
getProfile(@Req() req: any) {
|
getProfile(@Req() req: any) {
|
||||||
return this.usersService.findById(req.user.id);
|
return this.usersService.findById(req.user.id);
|
||||||
}
|
}
|
||||||
@ -21,7 +52,34 @@ export class UsersController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Patch('profile')
|
@Patch('profile')
|
||||||
@ApiOperation({ summary: 'ویرایش پروفایل کاربر فعلی' })
|
@ApiOperation({ summary: 'ویرایش پروفایل کاربر فعلی' })
|
||||||
@ApiResponse({ status: 200, description: 'پروفایل با موفقیت ویرایش شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'پروفایل با موفقیت ویرایش شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
firstName: 'علی',
|
||||||
|
lastName: 'احمدی',
|
||||||
|
email: 'ali@example.com',
|
||||||
|
mobile: '09123456789',
|
||||||
|
role: 'User_PetOwner',
|
||||||
|
walletBalance: '1500000.00',
|
||||||
|
charityDonationTotal: '25000.00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiBadRequestResponse({
|
||||||
|
description: 'خطا در صحتسنجی فیلدهای ورودی',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'ایمیل نامعتبر است',
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
details: {
|
||||||
|
message: ['ایمیل نامعتبر است']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) {
|
updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) {
|
||||||
return this.usersService.update(req.user.id, updateProfileDto);
|
return this.usersService.update(req.user.id, updateProfileDto);
|
||||||
}
|
}
|
||||||
@ -29,7 +87,24 @@ export class UsersController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('addresses')
|
@Post('addresses')
|
||||||
@ApiOperation({ summary: 'ایجاد آدرس جدید برای کاربر' })
|
@ApiOperation({ summary: 'ایجاد آدرس جدید برای کاربر' })
|
||||||
@ApiResponse({ status: 201, description: 'آدرس جدید با موفقیت ایجاد شد' })
|
@ApiCreatedResponse({
|
||||||
|
description: 'آدرس جدید با موفقیت ایجاد شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'b5a6c7d8-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
title: 'خانه',
|
||||||
|
receptorName: 'علی احمدی',
|
||||||
|
phone: '09123456789',
|
||||||
|
province: 'تهران',
|
||||||
|
city: 'تهران',
|
||||||
|
detail: 'خیابان آزادی، کوچه مریم، پلاک ۱۰',
|
||||||
|
zipCode: '1456789012',
|
||||||
|
isDefault: false,
|
||||||
|
createdAt: '2026-05-26T18:00:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
addAddress(@Req() req: any, @Body() addressDto: AddressDto) {
|
addAddress(@Req() req: any, @Body() addressDto: AddressDto) {
|
||||||
return this.usersService.addAddress(req.user.id, addressDto);
|
return this.usersService.addAddress(req.user.id, addressDto);
|
||||||
}
|
}
|
||||||
@ -37,7 +112,24 @@ export class UsersController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Patch('addresses/:addressId')
|
@Patch('addresses/:addressId')
|
||||||
@ApiOperation({ summary: 'ویرایش آدرس کاربر' })
|
@ApiOperation({ summary: 'ویرایش آدرس کاربر' })
|
||||||
@ApiResponse({ status: 200, description: 'آدرس با موفقیت ویرایش شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'آدرس با موفقیت ویرایش شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'b5a6c7d8-1234-5678-abcd-ef1234567890',
|
||||||
|
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
|
||||||
|
title: 'دفتر کار',
|
||||||
|
receptorName: 'علی احمدی',
|
||||||
|
phone: '09123456789',
|
||||||
|
province: 'تهران',
|
||||||
|
city: 'تهران',
|
||||||
|
detail: 'خیابان ولیعصر، برج سپهر، طبقه ۴',
|
||||||
|
zipCode: '1456789012',
|
||||||
|
isDefault: false,
|
||||||
|
createdAt: '2026-05-26T18:00:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
updateAddress(
|
updateAddress(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Param('addressId') addressId: string,
|
@Param('addressId') addressId: string,
|
||||||
@ -49,7 +141,15 @@ export class UsersController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Delete('addresses/:addressId')
|
@Delete('addresses/:addressId')
|
||||||
@ApiOperation({ summary: 'حذف آدرس کاربر' })
|
@ApiOperation({ summary: 'حذف آدرس کاربر' })
|
||||||
@ApiResponse({ status: 200, description: 'آدرس با موفقیت حذف شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'آدرس با موفقیت حذف شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
message: 'آدرس با موفقیت حذف شد'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
deleteAddress(@Req() req: any, @Param('addressId') addressId: string) {
|
deleteAddress(@Req() req: any, @Param('addressId') addressId: string) {
|
||||||
return this.usersService.deleteAddress(req.user.id, addressId);
|
return this.usersService.deleteAddress(req.user.id, addressId);
|
||||||
}
|
}
|
||||||
@ -57,7 +157,15 @@ export class UsersController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Patch('addresses/:addressId/default')
|
@Patch('addresses/:addressId/default')
|
||||||
@ApiOperation({ summary: 'انتخاب آدرس به عنوان پیشفرض' })
|
@ApiOperation({ summary: 'انتخاب آدرس به عنوان پیشفرض' })
|
||||||
@ApiResponse({ status: 200, description: 'آدرس به عنوان پیشفرض ثبت شد' })
|
@ApiOkResponse({
|
||||||
|
description: 'آدرس به عنوان پیشفرض ثبت شد',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: true,
|
||||||
|
message: 'آدرس پیشفرض با موفقیت تغییر کرد'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) {
|
setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) {
|
||||||
return this.usersService.setDefaultAddress(req.user.id, addressId);
|
return this.usersService.setDefaultAddress(req.user.id, addressId);
|
||||||
}
|
}
|
||||||
|
|||||||
101
backend/src/users/users.service.spec.ts
Normal file
101
backend/src/users/users.service.spec.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
describe('UsersService', () => {
|
||||||
|
let service: UsersService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockPrisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
userAddress: {
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
UsersService,
|
||||||
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UsersService>(UsersService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call prisma findUnique in findById', async () => {
|
||||||
|
const mockUser = { id: 'user-id' };
|
||||||
|
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
const result = await service.findById('user-id');
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call prisma update in update', async () => {
|
||||||
|
const data = { firstName: 'Ali' };
|
||||||
|
mockPrisma.user.update.mockResolvedValue({ id: 'user-id', ...data });
|
||||||
|
const result = await service.update('user-id', data);
|
||||||
|
expect(prisma.user.update).toHaveBeenCalled();
|
||||||
|
expect(result.firstName).toBe('Ali');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should addAddress', async () => {
|
||||||
|
const addressData = { title: 'Home', isDefault: true };
|
||||||
|
mockPrisma.userAddress.create.mockResolvedValue({ id: 'addr-id', ...addressData });
|
||||||
|
|
||||||
|
const result = await service.addAddress('user-id', addressData);
|
||||||
|
expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-id' },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
expect(prisma.userAddress.create).toHaveBeenCalled();
|
||||||
|
expect(result.id).toBe('addr-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should updateAddress', async () => {
|
||||||
|
const addressData = { title: 'Work', isDefault: true };
|
||||||
|
mockPrisma.userAddress.update.mockResolvedValue({ id: 'addr-id', ...addressData });
|
||||||
|
|
||||||
|
const result = await service.updateAddress('user-id', 'addr-id', addressData);
|
||||||
|
expect(prisma.userAddress.updateMany).toHaveBeenCalled();
|
||||||
|
expect(prisma.userAddress.update).toHaveBeenCalled();
|
||||||
|
expect(result.title).toBe('Work');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deleteAddress', async () => {
|
||||||
|
mockPrisma.userAddress.delete.mockResolvedValue({ id: 'addr-id' });
|
||||||
|
const result = await service.deleteAddress('user-id', 'addr-id');
|
||||||
|
expect(prisma.userAddress.delete).toHaveBeenCalled();
|
||||||
|
expect(result.id).toBe('addr-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should setDefaultAddress', async () => {
|
||||||
|
mockPrisma.userAddress.update.mockResolvedValue({ id: 'addr-id', isDefault: true });
|
||||||
|
const result = await service.setDefaultAddress('user-id', 'addr-id');
|
||||||
|
expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-id' },
|
||||||
|
data: { isDefault: false },
|
||||||
|
});
|
||||||
|
expect(prisma.userAddress.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'addr-id', userId: 'user-id' },
|
||||||
|
data: { isDefault: true },
|
||||||
|
});
|
||||||
|
expect(result.isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -45,9 +45,31 @@ services:
|
|||||||
container_name: canina_frontend
|
container_name: canina_frontend
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "3001:80"
|
- "3001:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.51.0
|
||||||
|
container_name: canina_prometheus
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.4.1
|
||||||
|
container_name: canina_grafana
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3002:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
10
nginx.conf
10
nginx.conf
@ -1,9 +1,17 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 8080;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security Headers
|
||||||
|
server_tokens off;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval' https://images.unsplash.com https://canina.de;" always;
|
||||||
|
|
||||||
# Support for SPA routing
|
# Support for SPA routing
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
1154
package-lock.json
generated
1154
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -8,7 +8,9 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist server.js",
|
"clean": "rm -rf dist server.js",
|
||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
@ -26,15 +28,20 @@
|
|||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
prometheus.yml
Normal file
8
prometheus.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'nest-backend'
|
||||||
|
metrics_path: '/api/metrics'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['backend:3000']
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { PRODUCTS, Product } from "../data/products";
|
import { Product } from "../data/products";
|
||||||
import { useCartStore } from "../store/cartStore";
|
import { useCartStore } from "../store/cartStore";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@ -20,12 +21,17 @@ interface QuickOrderItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function B2BPortal({ onClose }: { onClose: () => void }) {
|
export default function B2BPortal({ onClose }: { onClose: () => void }) {
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
const filteredProducts = PRODUCTS.filter(p =>
|
useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredProducts = products.filter(p =>
|
||||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
p.artNo.includes(searchQuery)
|
p.artNo.includes(searchQuery)
|
||||||
);
|
);
|
||||||
@ -39,7 +45,7 @@ export default function B2BPortal({ onClose }: { onClose: () => void }) {
|
|||||||
Object.entries(quantities).forEach(([id, qty]) => {
|
Object.entries(quantities).forEach(([id, qty]) => {
|
||||||
const numQty = qty as number;
|
const numQty = qty as number;
|
||||||
if (numQty > 0) {
|
if (numQty > 0) {
|
||||||
const product = PRODUCTS.find(p => p.id === id);
|
const product = products.find(p => p.id === id);
|
||||||
if (product) {
|
if (product) {
|
||||||
addItem(product, numQty);
|
addItem(product, numQty);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { ChevronRight, Calendar, User, ArrowLeft, Sparkles, BookOpen } from "lucide-react";
|
import { ChevronRight, Calendar, User, ArrowLeft, Sparkles, BookOpen } from "lucide-react";
|
||||||
import { Product, PRODUCTS } from "../data/products";
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
|
||||||
const BLOG_POSTS = [
|
const BLOG_POSTS = [
|
||||||
{
|
{
|
||||||
@ -42,6 +43,11 @@ const BLOG_POSTS = [
|
|||||||
|
|
||||||
export default function BlogPage({ onBack, onProductClick }: { onBack: () => void, onProductClick: (p: Product) => void }) {
|
export default function BlogPage({ onBack, onProductClick }: { onBack: () => void, onProductClick: (p: Product) => void }) {
|
||||||
const [selectedPost, setSelectedPost] = React.useState<typeof BLOG_POSTS[0] | null>(null);
|
const [selectedPost, setSelectedPost] = React.useState<typeof BLOG_POSTS[0] | null>(null);
|
||||||
|
const [products, setProducts] = React.useState<Product[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (selectedPost) {
|
if (selectedPost) {
|
||||||
return (
|
return (
|
||||||
@ -113,7 +119,7 @@ export default function BlogPage({ onBack, onProductClick }: { onBack: () => voi
|
|||||||
</div>
|
</div>
|
||||||
<h4 className="text-2xl font-black mb-6 italic relative z-10">محصولات پیشنهادی</h4>
|
<h4 className="text-2xl font-black mb-6 italic relative z-10">محصولات پیشنهادی</h4>
|
||||||
<div className="space-y-6 relative z-10">
|
<div className="space-y-6 relative z-10">
|
||||||
{PRODUCTS.slice(4, 7).map(p => (
|
{products.slice(4, 7).map(p => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => onProductClick(p)}
|
onClick={() => onProductClick(p)}
|
||||||
@ -316,7 +322,7 @@ export default function BlogPage({ onBack, onProductClick }: { onBack: () => voi
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 flex-shrink-0">
|
<div className="grid grid-cols-2 gap-4 flex-shrink-0">
|
||||||
{PRODUCTS.slice(0, 2).map((p) => (
|
{products.slice(0, 2).map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => onProductClick(p)}
|
onClick={() => onProductClick(p)}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { X, Trash2, Plus, Minus, ShoppingBag, ShieldCheck, ArrowLeft, Ticket, Sparkles, CheckCircle2 } from "lucide-react";
|
import { X, Trash2, Plus, Minus, ShoppingBag, ShieldCheck, ArrowLeft, Ticket, Sparkles, CheckCircle2 } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { toPersian, cn } from "../lib/utils";
|
import { toPersian, cn } from "../lib/utils";
|
||||||
import { useCartStore } from "../store/cartStore";
|
import { useCartStore } from "../store/cartStore";
|
||||||
import { PRODUCTS } from "../data/products";
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@ -32,10 +33,18 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate
|
|||||||
// Smart Cross-sell Logic
|
// Smart Cross-sell Logic
|
||||||
const hasCanhydrox = items.some(i => i.product.id === 'canhydrox-gag');
|
const hasCanhydrox = items.some(i => i.product.id === 'canhydrox-gag');
|
||||||
const hasLachsOl = items.some(i => i.product.id === 'lachs-ol');
|
const hasLachsOl = items.some(i => i.product.id === 'lachs-ol');
|
||||||
|
const [suggestedProduct, setSuggestedProduct] = useState<Product | null>(null);
|
||||||
const suggestedProduct = (!hasLachsOl && hasCanhydrox)
|
|
||||||
? PRODUCTS.find(p => p.id === 'lachs-ol')
|
useEffect(() => {
|
||||||
: null;
|
if (!hasLachsOl && hasCanhydrox) {
|
||||||
|
productService.getProducts().then(prods => {
|
||||||
|
const found = prods.find(p => p.id === 'lachs-ol' || p.artNo === 'lachs-ol');
|
||||||
|
setSuggestedProduct(found || null);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSuggestedProduct(null);
|
||||||
|
}
|
||||||
|
}, [hasLachsOl, hasCanhydrox]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@ -103,6 +112,7 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate
|
|||||||
<button
|
<button
|
||||||
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
|
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
|
||||||
className="p-1 hover:text-canina-blue transition-colors"
|
className="p-1 hover:text-canina-blue transition-colors"
|
||||||
|
data-testid="minus-btn"
|
||||||
>
|
>
|
||||||
<Minus className="w-3 h-3" />
|
<Minus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -110,6 +120,7 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate
|
|||||||
<button
|
<button
|
||||||
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
|
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
|
||||||
className="p-1 hover:text-canina-blue transition-colors"
|
className="p-1 hover:text-canina-blue transition-colors"
|
||||||
|
data-testid="plus-btn"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@ -19,7 +19,8 @@ import { toPersian, cn } from "../lib/utils";
|
|||||||
import { useCartStore } from "../store/cartStore";
|
import { useCartStore } from "../store/cartStore";
|
||||||
import { usePetStore } from "../store/usePetStore";
|
import { usePetStore } from "../store/usePetStore";
|
||||||
import { useUserStore } from "../store/userStore";
|
import { useUserStore } from "../store/userStore";
|
||||||
import { PRODUCTS } from "../data/products";
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function CheckoutPage({ onBack, onComplete }: { onBack: () => void; onComplete: (orderId: string) => void }) {
|
export default function CheckoutPage({ onBack, onComplete }: { onBack: () => void; onComplete: (orderId: string) => void }) {
|
||||||
@ -29,6 +30,11 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi
|
|||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [useRoundUp, setUseRoundUp] = useState(false);
|
const [useRoundUp, setUseRoundUp] = useState(false);
|
||||||
|
const [dbProducts, setDbProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setDbProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const subtotal = getSubtotal();
|
const subtotal = getSubtotal();
|
||||||
const roundedAmount = Math.ceil(subtotal / 10000) * 10000;
|
const roundedAmount = Math.ceil(subtotal / 10000) * 10000;
|
||||||
@ -329,7 +335,7 @@ export default function CheckoutPage({ onBack, onComplete }: { onBack: () => voi
|
|||||||
{items.map(item => {
|
{items.map(item => {
|
||||||
const activePet = usePetStore.getState().getActivePet();
|
const activePet = usePetStore.getState().getActivePet();
|
||||||
// Re-hydrate product to ensure methods like calculateDosage exist
|
// Re-hydrate product to ensure methods like calculateDosage exist
|
||||||
const fullProduct = PRODUCTS.find(p => p.id === item.product.id) || item.product;
|
const fullProduct = dbProducts.find(p => p.id === item.product.id) || item.product;
|
||||||
const dose = activePet ? fullProduct.calculateDosage(activePet.weight, activePet.age <= 1) : { quantity: 1 };
|
const dose = activePet ? fullProduct.calculateDosage(activePet.weight, activePet.age <= 1) : { quantity: 1 };
|
||||||
const days = Math.floor(fullProduct.packageSize / (dose.quantity || 1));
|
const days = Math.floor(fullProduct.packageSize / (dose.quantity || 1));
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import { INGREDIENTS_WIKI, PRODUCTS, Product } from "../data/products";
|
import { INGREDIENTS_WIKI, Product } from "../data/products";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { FlaskConical, CheckCircle2, ChevronRight, ChevronLeft, Beaker } from "lucide-react";
|
import { FlaskConical, CheckCircle2, ChevronRight, ChevronLeft, Beaker } from "lucide-react";
|
||||||
import { useSettingsStore } from "../store/settingsStore";
|
import { useSettingsStore } from "../store/settingsStore";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
|
||||||
export default function IngredientWiki({ onProductClick, onBack }: { onProductClick: (p: Product) => void, onBack?: () => void }) {
|
export default function IngredientWiki({ onProductClick, onBack }: { onProductClick: (p: Product) => void, onBack?: () => void }) {
|
||||||
const texts = useSettingsStore(state => state.texts);
|
const texts = useSettingsStore(state => state.texts);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts().then(data => setProducts(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const ingredientsWiki = useMemo(() => {
|
const ingredientsWiki = useMemo(() => {
|
||||||
const jsonStr = texts['ingredients_wiki'];
|
const jsonStr = texts['ingredients_wiki'];
|
||||||
if (jsonStr) {
|
if (jsonStr) {
|
||||||
@ -62,9 +68,9 @@ export default function IngredientWiki({ onProductClick, onBack }: { onProductCl
|
|||||||
|
|
||||||
<div className="space-y-32">
|
<div className="space-y-32">
|
||||||
{ingredientsWiki.map((ing, idx) => {
|
{ingredientsWiki.map((ing, idx) => {
|
||||||
const hasIngredient = PRODUCTS.filter(p =>
|
const hasIngredient = products.filter(p =>
|
||||||
p.main_ingredients.some(mi => mi.toLowerCase().includes(ing.name.toLowerCase())) ||
|
p.main_ingredients?.some(mi => mi.toLowerCase().includes(ing.name.toLowerCase())) ||
|
||||||
p.main_ingredients.some(mi => mi.toLowerCase().includes(ing.id.replace('-', ' ')))
|
p.main_ingredients?.some(mi => mi.toLowerCase().includes(ing.id.replace('-', ' ')))
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useMemo, useEffect } from "react";
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
import { PRODUCTS, Product, PetType } from "../data/products";
|
import { Product, PetType } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { PetProfileSkeleton } from "./Skeleton";
|
import { PetProfileSkeleton } from "./Skeleton";
|
||||||
import {
|
import {
|
||||||
@ -43,6 +44,13 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
|
|||||||
const { orders } = useCartStore();
|
const { orders } = useCartStore();
|
||||||
const activePet = getActivePet();
|
const activePet = getActivePet();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts()
|
||||||
|
.then(setProducts)
|
||||||
|
.catch(err => console.error("Error fetching products in PetProfile:", err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setIsLoading(false), 800);
|
const timer = setTimeout(() => setIsLoading(false), 800);
|
||||||
@ -151,50 +159,50 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
|
|||||||
};
|
};
|
||||||
|
|
||||||
const recommendedProducts = useMemo(() => {
|
const recommendedProducts = useMemo(() => {
|
||||||
if (!activePet) return [];
|
if (!activePet || products.length === 0) return [];
|
||||||
|
|
||||||
let picks: { product: Product, reason?: string }[] = [];
|
let picks: { product: Product, reason?: string }[] = [];
|
||||||
|
|
||||||
// Priority: Medical Conditions
|
// Priority: Medical Conditions
|
||||||
if (activePet.medicalConditions.includes("جراحی مفاصل")) {
|
if (activePet.medicalConditions.includes("جراحی مفاصل")) {
|
||||||
const gag = PRODUCTS.find(p => p.id === "canhydrox-gag");
|
const gag = products.find(p => p.id === "canhydrox-gag");
|
||||||
if (gag) picks.push({ product: gag, reason: "پیشنهاد ویژه بر اساس سوابق جراحی مفاصل" });
|
if (gag) picks.push({ product: gag, reason: "پیشنهاد ویژه بر اساس سوابق جراحی مفاصل" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePet.medicalConditions.includes("زایمان اخیر") || activePet.medicalConditions.includes("بارداری")) {
|
if (activePet.medicalConditions.includes("زایمان اخیر") || activePet.medicalConditions.includes("بارداری")) {
|
||||||
const booster = PRODUCTS.find(p => p.id === "immun-booster");
|
const booster = products.find(p => p.id === "immun-booster");
|
||||||
if (booster) picks.push({ product: booster, reason: "حمایت حیاتی دوران بارداری و زایمان" });
|
if (booster) picks.push({ product: booster, reason: "حمایت حیاتی دوران بارداری و زایمان" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePet.medicalConditions.includes("مشکلات گوارشی")) {
|
if (activePet.medicalConditions.includes("مشکلات گوارشی")) {
|
||||||
const moor = PRODUCTS.find(p => p.id === "moor-tranke");
|
const moor = products.find(p => p.id === "moor-tranke");
|
||||||
if (moor) picks.push({ product: moor, reason: "تنظیم تخصصی سیستم گوارش" });
|
if (moor) picks.push({ product: moor, reason: "تنظیم تخصصی سیستم گوارش" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePet.medicalConditions.includes("ریزش موی شدید")) {
|
if (activePet.medicalConditions.includes("ریزش موی شدید")) {
|
||||||
const lachs = PRODUCTS.find(p => p.id === "lachs-ol");
|
const lachs = products.find(p => p.id === "lachs-ol");
|
||||||
if (lachs) picks.push({ product: lachs, reason: "تقویت تارهای مو و کنترل ریزش" });
|
if (lachs) picks.push({ product: lachs, reason: "تقویت تارهای مو و کنترل ریزش" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activePet.medicalConditions.includes("بیاشتهایی")) {
|
if (activePet.medicalConditions.includes("بیاشتهایی")) {
|
||||||
const energy = PRODUCTS.find(p => p.id === "energy-gel");
|
const energy = products.find(p => p.id === "energy-gel");
|
||||||
if (energy) picks.push({ product: energy, reason: "تامین فوری انرژی و تحریک اشتها" });
|
if (energy) picks.push({ product: energy, reason: "تامین فوری انرژی و تحریک اشتها" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Biological factors
|
// Biological factors
|
||||||
if (activePet.age > 7) {
|
if (activePet.age > 7) {
|
||||||
const heart = PRODUCTS.find(p => p.id === "herz-vital");
|
const heart = products.find(p => p.id === "herz-vital");
|
||||||
if (heart && !picks.some(x => x.product.id === heart.id)) picks.push({ product: heart, reason: "محافظت از قلب پتهای مسن" });
|
if (heart && !picks.some(x => x.product.id === heart.id)) picks.push({ product: heart, reason: "محافظت از قلب پتهای مسن" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill defaults if needed
|
// Fill defaults if needed
|
||||||
if (picks.length < 4) {
|
if (picks.length < 4) {
|
||||||
const defaults = PRODUCTS.filter(p => !picks.some(x => x.product.id === p.id) && (p.suitableFor === activePet.type || p.suitableFor === "هر دو"));
|
const defaults = products.filter(p => !picks.some(x => x.product.id === p.id) && (p.suitableFor === activePet.type || p.suitableFor === "هر دو"));
|
||||||
defaults.slice(0, 4 - picks.length).forEach(p => picks.push({ product: p }));
|
defaults.slice(0, 4 - picks.length).forEach(p => picks.push({ product: p }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return picks;
|
return picks;
|
||||||
}, [activePet]);
|
}, [activePet, products]);
|
||||||
|
|
||||||
const handleNext = () => setStep(s => s + 1);
|
const handleNext = () => setStep(s => s + 1);
|
||||||
const handleBackStep = () => setStep(s => s - 1);
|
const handleBackStep = () => setStep(s => s - 1);
|
||||||
@ -716,7 +724,7 @@ export default function PetProfile({ onProductClick, onBack, initialView, adviso
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
activePet.consumptions.map((consumption) => {
|
activePet.consumptions.map((consumption) => {
|
||||||
const product = PRODUCTS.find(p => p.id === consumption.productId);
|
const product = products.find(p => p.id === consumption.productId);
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
|
|
||||||
const percentage = (consumption.remaining / (consumption.packageSize || 1)) * 100;
|
const percentage = (consumption.remaining / (consumption.packageSize || 1)) * 100;
|
||||||
|
|||||||
@ -40,7 +40,8 @@ const ICON_MAP: Record<string, any> = {
|
|||||||
Users
|
Users
|
||||||
};
|
};
|
||||||
import { toPersian, cn } from "../lib/utils";
|
import { toPersian, cn } from "../lib/utils";
|
||||||
import { Product, PRODUCTS } from "../data/products";
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
|
import { SCIENTIFIC_TERMS } from "../data/scientificTerms";
|
||||||
import { useSettingsStore } from "../store/settingsStore";
|
import { useSettingsStore } from "../store/settingsStore";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
@ -75,6 +76,13 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
|
|||||||
const [activeTab, setActiveTab] = useState<"specs" | "feeding" | "notes">("specs");
|
const [activeTab, setActiveTab] = useState<"specs" | "feeding" | "notes">("specs");
|
||||||
const [showRefillModal, setShowRefillModal] = useState(false);
|
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||||
const [itemQuantity, setItemQuantity] = useState(1);
|
const [itemQuantity, setItemQuantity] = useState(1);
|
||||||
|
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getProducts()
|
||||||
|
.then(setAllProducts)
|
||||||
|
.catch(err => console.error("Error fetching products in ProductPage:", err));
|
||||||
|
}, []);
|
||||||
const { pets, getActivePet } = usePetStore();
|
const { pets, getActivePet } = usePetStore();
|
||||||
const activePet = getActivePet();
|
const activePet = getActivePet();
|
||||||
const { petType, weight, setPetType, setWeight } = useCalculatorStore();
|
const { petType, weight, setPetType, setWeight } = useCalculatorStore();
|
||||||
@ -89,8 +97,8 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
|
|||||||
|
|
||||||
// Re-hydrate product to ensure methods like calculateDosage exist
|
// Re-hydrate product to ensure methods like calculateDosage exist
|
||||||
const fullProduct = useMemo(() => {
|
const fullProduct = useMemo(() => {
|
||||||
return PRODUCTS.find(p => p.id === product.id) || product;
|
return allProducts.find(p => p.id === product.id) || product;
|
||||||
}, [product]);
|
}, [product, allProducts]);
|
||||||
|
|
||||||
const syncActivePet = useMemo(() => {
|
const syncActivePet = useMemo(() => {
|
||||||
if (activePet) {
|
if (activePet) {
|
||||||
@ -490,7 +498,7 @@ export default function ProductPage({ product, onBack, onProductClick, onWikiNav
|
|||||||
|
|
||||||
<div className="grid lg:grid-cols-2 gap-8">
|
<div className="grid lg:grid-cols-2 gap-8">
|
||||||
{product.relatedProducts.map(relId => {
|
{product.relatedProducts.map(relId => {
|
||||||
const relProduct = PRODUCTS.find(p => p.id === relId);
|
const relProduct = allProducts.find(p => p.id === relId);
|
||||||
if (!relProduct) return null;
|
if (!relProduct) return null;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@ -1,21 +1,29 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
import { Search, ShoppingBag, ChevronLeft, ArrowRight, Activity, Sparkles, HeartPulse, Stethoscope, ShieldCheck, Heart, AlertCircle } from "lucide-react";
|
import { Search, ShoppingBag, ChevronLeft, ArrowRight, Activity, Sparkles, HeartPulse, Stethoscope, ShieldCheck, Heart, AlertCircle } from "lucide-react";
|
||||||
import { usePetStore } from "../store/usePetStore";
|
import { usePetStore } from "../store/usePetStore";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Product, PRODUCTS } from "../data/products";
|
import { Product } from "../data/products";
|
||||||
|
import { productService } from "../services/productService";
|
||||||
|
|
||||||
export default function SearchResultsPage({ query, onProductClick, onBack }: { query: string; onProductClick: (p: Product) => void; onBack: () => void }) {
|
export default function SearchResultsPage({ query, onProductClick, onBack }: { query: string; onProductClick: (p: Product) => void; onBack: () => void }) {
|
||||||
const { getActivePet } = usePetStore();
|
const { getActivePet } = usePetStore();
|
||||||
const activePet = getActivePet();
|
const activePet = getActivePet();
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
|
||||||
const results = PRODUCTS.filter(p =>
|
useEffect(() => {
|
||||||
|
productService.getProducts()
|
||||||
|
.then(setProducts)
|
||||||
|
.catch(err => console.error("Error fetching products in SearchResultsPage:", err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const results = products.filter(p =>
|
||||||
p.name.toLowerCase().includes(query.toLowerCase()) ||
|
p.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
p.description.toLowerCase().includes(query.toLowerCase()) ||
|
p.description.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
p.main_ingredients.some(ing => ing.toLowerCase().includes(query.toLowerCase())) ||
|
p.main_ingredients.some(ing => ing.toLowerCase().includes(query.toLowerCase())) ||
|
||||||
p.symptoms.some(sym => sym.toLowerCase().includes(query.toLowerCase()))
|
p.symptoms.some(sym => sym.toLowerCase().includes(query.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
const bestSellers = PRODUCTS.slice(0, 3);
|
const bestSellers = products.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-medical-gray-50 py-20 px-6 font-vazir" dir="rtl">
|
<div className="min-h-screen bg-medical-gray-50 py-20 px-6 font-vazir" dir="rtl">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } from "lucide-react";
|
||||||
import { Product, PRODUCTS } from "../data/products";
|
|
||||||
|
|
||||||
const ALL_VIDEOS = [
|
const ALL_VIDEOS = [
|
||||||
{
|
{
|
||||||
|
|||||||
101
src/components/__tests__/CartDrawer.test.tsx
Normal file
101
src/components/__tests__/CartDrawer.test.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import CartDrawer from '../CartDrawer';
|
||||||
|
import { useCartStore } from '../../store/cartStore';
|
||||||
|
import { productService } from '../../services/productService';
|
||||||
|
|
||||||
|
vi.mock('../../store/cartStore', () => ({
|
||||||
|
useCartStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/productService', () => ({
|
||||||
|
productService: {
|
||||||
|
getProducts: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProduct = {
|
||||||
|
id: 'canhydrox-gag',
|
||||||
|
name: 'Canhydrox GAG',
|
||||||
|
price: '۱,۰۰۰ تومان',
|
||||||
|
priceValue: 1000,
|
||||||
|
category: 'joints',
|
||||||
|
image: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CartDrawer', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(productService.getProducts).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty cart state when no items in cart', () => {
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
items: [],
|
||||||
|
updateQuantity: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
isSubscribed: false,
|
||||||
|
toggleSubscription: vi.fn(),
|
||||||
|
getTotal: () => 0,
|
||||||
|
getSubtotal: () => 0,
|
||||||
|
getDiscount: () => 0,
|
||||||
|
coupon: null,
|
||||||
|
applyCoupon: vi.fn(),
|
||||||
|
addItem: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('سبد خرید شما خالی است')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cart items and total when items are present', () => {
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
items: [{ product: mockProduct, quantity: 2 }],
|
||||||
|
updateQuantity: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
isSubscribed: false,
|
||||||
|
toggleSubscription: vi.fn(),
|
||||||
|
getTotal: () => 2000,
|
||||||
|
getSubtotal: () => 2000,
|
||||||
|
getDiscount: () => 0,
|
||||||
|
coupon: null,
|
||||||
|
applyCoupon: vi.fn(),
|
||||||
|
addItem: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('۲,۰۰۰').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers updateQuantity when plus/minus buttons are clicked', () => {
|
||||||
|
const mockUpdateQuantity = vi.fn();
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
items: [{ product: mockProduct, quantity: 2 }],
|
||||||
|
updateQuantity: mockUpdateQuantity,
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
isSubscribed: false,
|
||||||
|
toggleSubscription: vi.fn(),
|
||||||
|
getTotal: () => 2000,
|
||||||
|
getSubtotal: () => 2000,
|
||||||
|
getDiscount: () => 0,
|
||||||
|
coupon: null,
|
||||||
|
applyCoupon: vi.fn(),
|
||||||
|
addItem: vi.fn(),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<CartDrawer isOpen={true} onClose={vi.fn()} onCheckout={vi.fn()} />);
|
||||||
|
|
||||||
|
const plusBtn = screen.getByTestId('plus-btn');
|
||||||
|
const minusBtn = screen.getByTestId('minus-btn');
|
||||||
|
|
||||||
|
fireEvent.click(plusBtn);
|
||||||
|
expect(mockUpdateQuantity).toHaveBeenCalledWith('canhydrox-gag', 3);
|
||||||
|
|
||||||
|
fireEvent.click(minusBtn);
|
||||||
|
expect(mockUpdateQuantity).toHaveBeenCalledWith('canhydrox-gag', 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
src/components/__tests__/FeaturedProducts.test.tsx
Normal file
81
src/components/__tests__/FeaturedProducts.test.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import FeaturedProducts from '../FeaturedProducts';
|
||||||
|
import { productService } from '../../services/productService';
|
||||||
|
import { usePetStore } from '../../store/usePetStore';
|
||||||
|
|
||||||
|
vi.mock('../../services/productService', () => ({
|
||||||
|
productService: {
|
||||||
|
getFeaturedProducts: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/usePetStore', () => ({
|
||||||
|
usePetStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProducts = [
|
||||||
|
{
|
||||||
|
id: 'prod-1',
|
||||||
|
name: 'Canhydrox GAG',
|
||||||
|
description: 'Joint helper',
|
||||||
|
price: '۱,۰۰۰ تومان',
|
||||||
|
priceValue: 1000,
|
||||||
|
category: 'joints',
|
||||||
|
image: '',
|
||||||
|
suitableFor: 'سگ',
|
||||||
|
symptoms: ['joint_pain'],
|
||||||
|
benefits: 'Strengthens joints',
|
||||||
|
calculateDosage: vi.fn(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('FeaturedProducts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
|
getActivePet: () => null,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading skeletons initially', () => {
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockReturnValue(new Promise(() => {}));
|
||||||
|
const { container } = render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
||||||
|
// Check if skeletons are present (e.g. searching for animate-pulse)
|
||||||
|
expect(container.getElementsByClassName('animate-pulse').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders products once loaded', async () => {
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Joint helper')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onProductClick when product card is clicked', async () => {
|
||||||
|
const handleProductClick = vi.fn();
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
|
render(<FeaturedProducts onProductClick={handleProductClick} onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Canhydrox GAG')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Canhydrox GAG'));
|
||||||
|
expect(handleProductClick).toHaveBeenCalledWith(mockProducts[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onShopNavigate when navigation link is clicked', async () => {
|
||||||
|
const handleShopNavigate = vi.fn();
|
||||||
|
vi.mocked(productService.getFeaturedProducts).mockResolvedValue(mockProducts as any);
|
||||||
|
render(<FeaturedProducts onProductClick={vi.fn()} onShopNavigate={handleShopNavigate} />);
|
||||||
|
|
||||||
|
const navBtn = screen.getByText('مشاهده تمامی محصولات');
|
||||||
|
fireEvent.click(navBtn);
|
||||||
|
expect(handleShopNavigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/components/__tests__/Footer.test.tsx
Normal file
43
src/components/__tests__/Footer.test.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Footer from '../Footer';
|
||||||
|
|
||||||
|
describe('Footer', () => {
|
||||||
|
it('renders footer brand text and standard layout elements', () => {
|
||||||
|
render(<Footer onNavigate={vi.fn()} onShopNavigate={vi.fn()} onB2BOpen={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('کانینا ایران')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('نماینده رسمی در ایران')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers onNavigate and onShopNavigate when quick links are clicked', () => {
|
||||||
|
const handleNavigate = vi.fn();
|
||||||
|
const handleShopNavigate = vi.fn();
|
||||||
|
const handleB2BOpen = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Footer
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onShopNavigate={handleShopNavigate}
|
||||||
|
onB2BOpen={handleB2BOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click quick link for shop
|
||||||
|
const shopLink = screen.getByText('محصولات تخصصی ۲۰۲۴');
|
||||||
|
fireEvent.click(shopLink);
|
||||||
|
expect(handleShopNavigate).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Click quick link for blog
|
||||||
|
const blogLink = screen.getByText('مجله سلامت پت (وبلاگ)');
|
||||||
|
fireEvent.click(blogLink);
|
||||||
|
expect(handleNavigate).toHaveBeenCalledWith('blog');
|
||||||
|
|
||||||
|
// Click B2B link
|
||||||
|
const b2bLink = screen.getByText('پنل سفارش عمده (B2B)');
|
||||||
|
fireEvent.click(b2bLink);
|
||||||
|
expect(handleB2BOpen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
110
src/components/__tests__/Header.test.tsx
Normal file
110
src/components/__tests__/Header.test.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Header from '../Header';
|
||||||
|
import { useCartStore } from '../../store/cartStore';
|
||||||
|
import { usePetStore } from '../../store/usePetStore';
|
||||||
|
import { useUserStore } from '../../store/userStore';
|
||||||
|
|
||||||
|
vi.mock('../../store/cartStore', () => ({
|
||||||
|
useCartStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/usePetStore', () => ({
|
||||||
|
usePetStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../store/userStore', () => ({
|
||||||
|
useUserStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
vi.mocked(useCartStore).mockReturnValue({
|
||||||
|
getTotalItems: () => 3,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
|
pets: [],
|
||||||
|
activePetId: null,
|
||||||
|
setActivePet: vi.fn(),
|
||||||
|
getActivePet: () => null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useUserStore).mockReturnValue({
|
||||||
|
role: 'User_Guest',
|
||||||
|
isLoggedIn: false,
|
||||||
|
logout: vi.fn(),
|
||||||
|
profile: { firstName: '' },
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders brand name and login button when guest', () => {
|
||||||
|
render(
|
||||||
|
<Header
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onShopNavigate={vi.fn()}
|
||||||
|
currentView="home"
|
||||||
|
onCartOpen={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onB2BOpen={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Canina')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ایران')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ورود / ثبتنام')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders user first name and pet selection when logged in', () => {
|
||||||
|
vi.mocked(useUserStore).mockReturnValue({
|
||||||
|
role: 'User_PetOwner',
|
||||||
|
isLoggedIn: true,
|
||||||
|
logout: vi.fn(),
|
||||||
|
profile: { firstName: 'کوروش' },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(usePetStore).mockReturnValue({
|
||||||
|
pets: [{ id: 'pet-1', name: 'ملوس', type: 'گربه' }],
|
||||||
|
activePetId: 'pet-1',
|
||||||
|
setActivePet: vi.fn(),
|
||||||
|
getActivePet: () => ({ id: 'pet-1', name: 'ملوس', type: 'گربه' }),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Header
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onShopNavigate={vi.fn()}
|
||||||
|
currentView="home"
|
||||||
|
onCartOpen={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onB2BOpen={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('ورود / ثبتنام')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('کوروش')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ملوس')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCartOpen when click on cart button', () => {
|
||||||
|
const handleCartOpen = vi.fn();
|
||||||
|
render(
|
||||||
|
<Header
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onShopNavigate={vi.fn()}
|
||||||
|
currentView="home"
|
||||||
|
onCartOpen={handleCartOpen}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onB2BOpen={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cartBtn = screen.getByText('سبد خرید').closest('button');
|
||||||
|
expect(cartBtn).toBeInTheDocument();
|
||||||
|
fireEvent.click(cartBtn!);
|
||||||
|
expect(handleCartOpen).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/components/__tests__/Hero.test.tsx
Normal file
43
src/components/__tests__/Hero.test.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Hero from '../Hero';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
|
||||||
|
vi.mock('../../store/settingsStore', () => ({
|
||||||
|
useSettingsStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Hero', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title and description from settingsStore', () => {
|
||||||
|
const mockGetText = vi.fn().mockImplementation((key, fallback) => {
|
||||||
|
if (key === 'hero_title') return 'Test Title Line 1\nTest Title Line 2';
|
||||||
|
if (key === 'hero_desc') return 'Test Description Text';
|
||||||
|
return fallback;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||||
|
|
||||||
|
render(<Hero onShopNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Title Line 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Title Line 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test Description Text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onShopNavigate when "مشاهده محصولات" button is clicked', () => {
|
||||||
|
const mockGetText = vi.fn().mockImplementation((key, fallback) => fallback);
|
||||||
|
vi.mocked(useSettingsStore).mockImplementation((selector: any) => selector({ getText: mockGetText }));
|
||||||
|
|
||||||
|
const handleShopNavigate = vi.fn();
|
||||||
|
render(<Hero onShopNavigate={handleShopNavigate} />);
|
||||||
|
|
||||||
|
const shopBtn = screen.getByText('مشاهده محصولات');
|
||||||
|
fireEvent.click(shopBtn);
|
||||||
|
expect(handleShopNavigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
src/components/__tests__/Tooltip.test.tsx
Normal file
50
src/components/__tests__/Tooltip.test.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
|
||||||
|
vi.mock('../../store/settingsStore', () => ({
|
||||||
|
useSettingsStore: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Tooltip', () => {
|
||||||
|
it('should render children correctly', () => {
|
||||||
|
vi.mocked(useSettingsStore).mockReturnValue({});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Tooltip termKey="mussel">
|
||||||
|
<span>Mussel Term</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Mussel Term')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display tooltip definition on hover', async () => {
|
||||||
|
const mockTerms = {
|
||||||
|
mussel: {
|
||||||
|
key: 'mussel',
|
||||||
|
term: 'Green Mussel',
|
||||||
|
definition: 'Scientific definition of NZ green mussel',
|
||||||
|
wikiId: 'green-mussel',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vi.mocked(useSettingsStore).mockReturnValue(mockTerms);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Tooltip termKey="mussel">
|
||||||
|
<span>Hover Me</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = screen.getByText('Hover Me');
|
||||||
|
fireEvent.mouseEnter(trigger);
|
||||||
|
|
||||||
|
expect(screen.getByText('Green Mussel')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Scientific definition of NZ green mussel')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.mouseLeave(trigger);
|
||||||
|
// Tooltip should exit (though animations might delay actual removal, under standard RTL fireEvent it updates state immediately)
|
||||||
|
});
|
||||||
|
});
|
||||||
142
src/store/__tests__/cartStore.test.ts
Normal file
142
src/store/__tests__/cartStore.test.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useCartStore } from '../cartStore';
|
||||||
|
import { orderService } from '../../services/orderService';
|
||||||
|
|
||||||
|
vi.mock('../../services/orderService', () => ({
|
||||||
|
orderService: {
|
||||||
|
createOrder: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockProduct = {
|
||||||
|
id: 'prod-1',
|
||||||
|
artNo: 'art-1',
|
||||||
|
name: 'Canhydrox GAG',
|
||||||
|
scientificTagline: 'Joint helper',
|
||||||
|
description: 'Joints',
|
||||||
|
price: '۱,۰۰۰ تومان',
|
||||||
|
priceValue: 1000,
|
||||||
|
category: 'joints',
|
||||||
|
unit: 'tablet',
|
||||||
|
packageSize: 100,
|
||||||
|
main_ingredients: [],
|
||||||
|
dosage_logic: '1',
|
||||||
|
benefits: '',
|
||||||
|
symptoms: [],
|
||||||
|
suitableFor: 'سگ' as any,
|
||||||
|
calculateDosage: () => ({ quantity: 1, unit: 'tablet', description: '' }),
|
||||||
|
analysis: {},
|
||||||
|
feedingAdvice: '',
|
||||||
|
specialist: null as any,
|
||||||
|
image: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('cartStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useCartStore.setState({
|
||||||
|
items: [],
|
||||||
|
isSubscribed: false,
|
||||||
|
charityDonation: 0,
|
||||||
|
coupon: null,
|
||||||
|
orders: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add item to cart', () => {
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
store.addItem(mockProduct, 2);
|
||||||
|
|
||||||
|
const state = useCartStore.getState();
|
||||||
|
expect(state.items).toHaveLength(1);
|
||||||
|
expect(state.items[0].product.id).toBe('prod-1');
|
||||||
|
expect(state.items[0].quantity).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment quantity if item already in cart', () => {
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
store.addItem(mockProduct, 2);
|
||||||
|
store.addItem(mockProduct, 3);
|
||||||
|
|
||||||
|
const state = useCartStore.getState();
|
||||||
|
expect(state.items).toHaveLength(1);
|
||||||
|
expect(state.items[0].quantity).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove item from cart', () => {
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
store.addItem(mockProduct, 2);
|
||||||
|
store.removeItem('prod-1');
|
||||||
|
|
||||||
|
const state = useCartStore.getState();
|
||||||
|
expect(state.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update quantity', () => {
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
store.addItem(mockProduct, 2);
|
||||||
|
store.updateQuantity('prod-1', 4);
|
||||||
|
|
||||||
|
const state = useCartStore.getState();
|
||||||
|
expect(state.items[0].quantity).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove item if update quantity is <= 0', () => {
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
store.addItem(mockProduct, 2);
|
||||||
|
store.updateQuantity('prod-1', 0);
|
||||||
|
|
||||||
|
const state = useCartStore.getState();
|
||||||
|
expect(state.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply discount coupon', () => {
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
const success = store.applyCoupon('CANINA2024');
|
||||||
|
expect(success).toBe(true);
|
||||||
|
|
||||||
|
const state = useCartStore.getState();
|
||||||
|
expect(state.coupon).toEqual({ code: 'CANINA2024', discount: 0.1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate subtotal, discount and total', () => {
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
store.addItem(mockProduct, 2); // 2 * 1000 = 2000
|
||||||
|
store.applyCoupon('CANINA2024'); // 10% discount
|
||||||
|
|
||||||
|
expect(useCartStore.getState().getSubtotal()).toBe(2000);
|
||||||
|
expect(useCartStore.getState().getDiscount()).toBe(200);
|
||||||
|
expect(useCartStore.getState().getTotal()).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create order successfully via orderService', async () => {
|
||||||
|
const mockBackendOrder = {
|
||||||
|
id: 'backend-order-id',
|
||||||
|
createdAt: '2026-05-26T18:00:00.000Z',
|
||||||
|
totalAmount: '2000.00',
|
||||||
|
charityDonation: '0.00',
|
||||||
|
status: 'processing',
|
||||||
|
trackingNumber: 'TRK-12345',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(orderService.createOrder).mockResolvedValue(mockBackendOrder);
|
||||||
|
|
||||||
|
const store = useCartStore.getState();
|
||||||
|
store.addItem(mockProduct, 2);
|
||||||
|
|
||||||
|
const orderId = await store.addOrder({
|
||||||
|
items: store.items,
|
||||||
|
total: 2000,
|
||||||
|
charityDonation: 0,
|
||||||
|
petId: 'pet-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(orderService.createOrder).toHaveBeenCalled();
|
||||||
|
expect(orderId).toBe('backend-order-id');
|
||||||
|
|
||||||
|
const state = useCartStore.getState();
|
||||||
|
expect(state.orders).toHaveLength(1);
|
||||||
|
expect(state.orders[0].id).toBe('backend-order-id');
|
||||||
|
expect(state.orders[0].total).toBe(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/store/__tests__/settingsStore.test.ts
Normal file
62
src/store/__tests__/settingsStore.test.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useSettingsStore } from '../settingsStore';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
vi.mock('../../services/api', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('settingsStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useSettingsStore.setState({
|
||||||
|
texts: {},
|
||||||
|
scientificTerms: {},
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback if key is not found', () => {
|
||||||
|
const text = useSettingsStore.getState().getText('non-existent', 'Fallback Text');
|
||||||
|
expect(text).toBe('Fallback Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return value if key is found', () => {
|
||||||
|
useSettingsStore.setState({
|
||||||
|
texts: { 'test-key': 'Real Value' },
|
||||||
|
});
|
||||||
|
const text = useSettingsStore.getState().getText('test-key', 'Fallback Text');
|
||||||
|
expect(text).toBe('Real Value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch settings successfully and map array to object', async () => {
|
||||||
|
const mockUiTexts = [
|
||||||
|
{ key: 'hero_title', value: 'Welcome to Canina' },
|
||||||
|
{ key: 'hero_desc', value: 'Best supplements' },
|
||||||
|
];
|
||||||
|
const mockTerms = [
|
||||||
|
{ key: 'mussel', term: 'Mussel', definition: 'NZ Green Mussel', wikiId: 'general' },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(api.get).mockImplementation((url: string) => {
|
||||||
|
if (url === '/settings/ui-texts') {
|
||||||
|
return Promise.resolve({ data: mockUiTexts });
|
||||||
|
}
|
||||||
|
if (url === '/settings/scientific-terms') {
|
||||||
|
return Promise.resolve({ data: mockTerms });
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('Unknown URL'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = useSettingsStore.getState();
|
||||||
|
await store.fetchSettings();
|
||||||
|
|
||||||
|
const state = useSettingsStore.getState();
|
||||||
|
expect(state.texts['hero_title']).toBe('Welcome to Canina');
|
||||||
|
expect(state.texts['hero_desc']).toBe('Best supplements');
|
||||||
|
expect(state.scientificTerms['mussel']).toEqual(mockTerms[0]);
|
||||||
|
expect(state.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
src/store/__tests__/userStore.test.ts
Normal file
133
src/store/__tests__/userStore.test.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { useUserStore } from '../userStore';
|
||||||
|
import { authService } from '../../services/authService';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
vi.mock('../../services/authService', () => ({
|
||||||
|
authService: {
|
||||||
|
updateProfile: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
getProfile: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/api', () => ({
|
||||||
|
default: {
|
||||||
|
post: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('userStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useUserStore.setState({
|
||||||
|
role: 'User_Guest',
|
||||||
|
isLoggedIn: false,
|
||||||
|
profile: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
mobile: '',
|
||||||
|
walletBalance: 0,
|
||||||
|
charityDonationTotal: 0,
|
||||||
|
addresses: [],
|
||||||
|
transactions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout correctly', () => {
|
||||||
|
useUserStore.setState({
|
||||||
|
role: 'User_PetOwner',
|
||||||
|
isLoggedIn: true,
|
||||||
|
profile: {
|
||||||
|
firstName: 'Ali',
|
||||||
|
lastName: 'Ahmadi',
|
||||||
|
email: 'ali@example.com',
|
||||||
|
mobile: '0912',
|
||||||
|
walletBalance: 100,
|
||||||
|
charityDonationTotal: 10,
|
||||||
|
addresses: [],
|
||||||
|
transactions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useUserStore.getState().logout();
|
||||||
|
|
||||||
|
expect(authService.logout).toHaveBeenCalled();
|
||||||
|
const state = useUserStore.getState();
|
||||||
|
expect(state.isLoggedIn).toBe(false);
|
||||||
|
expect(state.role).toBe('User_Guest');
|
||||||
|
expect(state.profile.firstName).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch profile successfully', async () => {
|
||||||
|
const mockProfileData = {
|
||||||
|
firstName: 'Ali',
|
||||||
|
lastName: 'Ahmadi',
|
||||||
|
email: 'ali@example.com',
|
||||||
|
mobile: '09123456789',
|
||||||
|
role: 'User_PetOwner',
|
||||||
|
walletBalance: '1500.00',
|
||||||
|
charityDonationTotal: '50.00',
|
||||||
|
addresses: [],
|
||||||
|
pets: [],
|
||||||
|
walletTransactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(authService.getProfile).mockResolvedValue(mockProfileData);
|
||||||
|
|
||||||
|
const store = useUserStore.getState();
|
||||||
|
await store.fetchProfile();
|
||||||
|
|
||||||
|
expect(authService.getProfile).toHaveBeenCalled();
|
||||||
|
const state = useUserStore.getState();
|
||||||
|
expect(state.isLoggedIn).toBe(true);
|
||||||
|
expect(state.role).toBe('User_PetOwner');
|
||||||
|
expect(state.profile.firstName).toBe('Ali');
|
||||||
|
expect(state.profile.walletBalance).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update profile correctly', async () => {
|
||||||
|
const mockUpdatedData = {
|
||||||
|
firstName: 'Ali Updated',
|
||||||
|
lastName: 'Ahmadi',
|
||||||
|
email: 'ali@example.com',
|
||||||
|
mobile: '09123456789',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(authService.updateProfile).mockResolvedValue(mockUpdatedData);
|
||||||
|
|
||||||
|
const store = useUserStore.getState();
|
||||||
|
await store.updateProfile({ firstName: 'Ali Updated' });
|
||||||
|
|
||||||
|
expect(authService.updateProfile).toHaveBeenCalledWith({ firstName: 'Ali Updated' });
|
||||||
|
const state = useUserStore.getState();
|
||||||
|
expect(state.profile.firstName).toBe('Ali Updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add address and call fetchProfile', async () => {
|
||||||
|
const address = {
|
||||||
|
id: 'addr-1',
|
||||||
|
title: 'Home',
|
||||||
|
receptorName: 'Ali',
|
||||||
|
phone: '0912',
|
||||||
|
province: 'Teh',
|
||||||
|
city: 'Teh',
|
||||||
|
detail: 'Det',
|
||||||
|
zipCode: '123',
|
||||||
|
isDefault: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(useUserStore.getState(), 'fetchProfile').mockResolvedValue(undefined);
|
||||||
|
vi.mocked(api.post).mockResolvedValue({ data: { success: true } });
|
||||||
|
|
||||||
|
const store = useUserStore.getState();
|
||||||
|
await store.addAddress(address);
|
||||||
|
|
||||||
|
expect(api.post).toHaveBeenCalled();
|
||||||
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/test/setup.ts
Normal file
67
src/test/setup.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
class LocalStorageMock {
|
||||||
|
private store: Record<string, string> = {};
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.store = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(key: string) {
|
||||||
|
return this.store[key] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
this.store[key] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string) {
|
||||||
|
delete this.store[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
get length() {
|
||||||
|
return Object.keys(this.store).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
key(index: number) {
|
||||||
|
return Object.keys(this.store)[index] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStorageMock = new LocalStorageMock();
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock IntersectionObserver for framer-motion/motion viewport triggers
|
||||||
|
class IntersectionObserverMock {
|
||||||
|
readonly root: Element | null = null;
|
||||||
|
readonly rootMargin: string = '';
|
||||||
|
readonly thresholds: ReadonlyArray<number> = [];
|
||||||
|
constructor(callback: any, options?: any) {}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() { return []; }
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, 'IntersectionObserver', {
|
||||||
|
value: IntersectionObserverMock,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock matchMedia if needed
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(query => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(), // deprecated
|
||||||
|
removeListener: vi.fn(), // deprecated
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@ -15,9 +16,19 @@ export default defineConfig(({mode}) => {
|
|||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
pool: 'forks',
|
||||||
|
forks: {
|
||||||
|
singleFork: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
||||||
hmr: process.env.DISABLE_HMR !== 'true',
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user