Глава 11. Интеграция Cursor с внешними инструментами
11.1. Системы контроля версий (Git)
Cursor имеет глубокую интеграцию с Git.
Source Control Panel
Откройте через Activity Bar или Cmd/Ctrl+Shift+G
SOURCE CONTROL
├─ Changes (5)
│ ├─ M src/components/UserProfile.tsx
│ ├─ M src/api/users.ts
│ ├─ A src/utils/validation.ts
│ ├─ D old-file.js
│ └─ ?? new-file.tsx
├─ Staged Changes (0)
└─ Merge Changes (0)AI-assisted Commits
Cursor может генерировать commit messages:
You: [Stage изменения]
Сгенерируй commit message для этих изменений
AI:feat(auth): add email validation to registration
- Implement email format validation
- Add async check for existing emails
- Update error messages for better UX
- Add unit tests for validation logic
Resolves #123Git Operations с AI помощью
Merge Conflicts:
Когда возникает конфликт:
<<<<<<< HEAD
const apiUrl = 'https://api.production.com';
=======
const apiUrl = 'https://api.staging.com';
>>>>>>> feature/new-apiПопросите AI:
You: Помоги разрешить этот merge conflict
AI: Похоже оба изменения относятся к разным окружениям.
Рекомендую использовать environment variable:
const apiUrl = process.env.API_URL || 'https://api.production.com';
В .env:
API_URL=https://api.production.com
В .env.development:
API_URL=https://api.staging.comBranch Management:
You: Какие ветки устарели и можно удалить?
AI: [Анализирует branches]
Рекомендации:
Можно удалить (merged и старше 30 дней):
- feature/old-login (merged 45 days ago)
- hotfix/bug-123 (merged 60 days ago)
- experiment/new-ui (merged 90 days ago)
Требуют внимания (не merged, давно не обновлялись):
- feature/payment-gateway (120 days old)
Рекомендация: обновить или закрыть
Активные:
- feature/new-dashboard (updated yesterday)
- bugfix/cart-issue (updated 2 days ago)Git History Analysis:
You: Когда и почему была изменена функция calculateDiscount?
AI: История изменений calculateDiscount():
1. commit a1b2c3d (2024-01-15) - John Doe
"fix: correct discount calculation for bulk orders"
- Изменил логику для заказов >10 items
2. commit d4e5f6g (2023-12-01) - Jane Smith
"feat: add VIP customer discount"
- Добавил проверку isVIP
3. commit h7i8j9k (2023-10-10) - John Doe
"refactor: simplify discount logic"
- Рефакторинг для читаемости
Основные изменения связаны с добавлением новых типов скидок.11.2. CI/CD пайплайны
AI может помогать создавать и отлаживать CI/CD конфигурации.
GitHub Actions
You: Создай GitHub Actions workflow для Node.js приложения
AI:name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
# Testing
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
# Security scanning
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run security audit
run: npm audit --audit-level=moderate
- name: Check for vulnerabilities
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
# Build
build:
runs-on: ubuntu-latest
needs: [test, security]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build
path: dist/
# Deploy to staging
deploy-staging:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v3
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: build
path: dist/
- name: Deploy to staging
run: |
# Your deployment script
npm run deploy:staging
env:
API_KEY: ${{ secrets.STAGING_API_KEY }}
# Deploy to production
deploy-production:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://example.com
steps:
- uses: actions/checkout@v3
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: build
path: dist/
- name: Deploy to production
run: |
npm run deploy:production
env:
API_KEY: ${{ secrets.PRODUCTION_API_KEY }}
- name: Notify team
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Deployment to production completed'
webhook_url: ${{ secrets.SLACK_WEBHOOK }}
GitLab CI
You: Конвертируй этот GitHub Actions workflow в GitLab CI
AI:# .gitlab-ci.yml
stages:
- test
- security
- build
- deploy
variables:
NODE_VERSION: "18"
# Testing
test:
stage: test
image: node:$NODE_VERSION
parallel:
matrix:
- NODE_VERSION: ["16", "18", "20"]
script:
- npm ci
- npm run lint
- npm test -- --coverage
coverage: '/Lines\s*:\s*(\d+\.\d+)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
# Security
security:
stage: security
image: node:18
script:
- npm audit --audit-level=moderate
allow_failure: true
# Build
build:
stage: build
image: node:18
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 week
only:
- main
- develop
# Deploy Staging
deploy:staging:
stage: deploy
image: node:18
script:
- npm run deploy:staging
environment:
name: staging
url: https://staging.example.com
only:
- develop
# Deploy Production
deploy:production:
stage: deploy
image: node:18
script:
- npm run deploy:production
environment:
name: production
url: https://example.com
only:
- main
when: manual # Require manual trigger
Debugging CI/CD
Когда pipeline падает:
You: [Paste error log]
Pipeline упал с этой ошибкой. Как исправить?
AI: Анализ ошибки:
Ошибка: "Cannot find module 'dotenv'"
Проблема:
В CI среде не установлен пакет dotenv, который нужен
для тестов.
Решение:
1. Добавьте dotenv в dependencies (не devDependencies):
npm install --save dotenv
2. Или установите все dev dependencies в CI:
npm ci (вместо npm ci --only=production)
3. Или создайте .env.test для CI:
echo "NODE_ENV=test" > .env.test
Рекомендуемое исправление в .github/workflows/ci.yml:
- name: Create test environment file
run: |
echo "NODE_ENV=test" > .env.test
echo "DATABASE_URL=postgres://test" >> .env.test
- name: Run tests
run: npm test
env:
NODE_ENV: test11.3. Базы данных
AI помогает с database операциями, миграциями и оптимизацией.
Schema Design
You: Создай database schema для блог-платформы
AI:-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
bio TEXT,
avatar_url VARCHAR(500),
email_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
-- Posts table
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
content TEXT NOT NULL,
excerpt TEXT,
featured_image VARCHAR(500),
status VARCHAR(20) DEFAULT 'draft', -- draft, published, archived
published_at TIMESTAMP NULL,
view_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
-- Categories table
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Post-Category junction table (many-to-many)
CREATE TABLE post_categories (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, category_id)
);
-- Tags table
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL,
slug VARCHAR(50) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Post-Tag junction table
CREATE TABLE post_tags (
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- Comments table
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
parent_id UUID REFERENCES comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, approved, spam
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
-- Likes table
CREATE TABLE likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, post_id)
);
-- Followers table (user following)
CREATE TABLE followers (
follower_id UUID REFERENCES users(id) ON DELETE CASCADE,
following_id UUID REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (follower_id, following_id),
CHECK (follower_id != following_id)
);
-- Indexes for performance
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_published ON posts(published_at);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_comments_post ON comments(post_id);
CREATE INDEX idx_comments_author ON comments(author_id);
CREATE INDEX idx_likes_post ON likes(post_id);
CREATE INDEX idx_likes_user ON likes(user_id);
CREATE INDEX idx_followers_following ON followers(following_id);
-- Full-text search index
CREATE INDEX idx_posts_search ON posts USING GIN(to_tsvector('english', title || ' ' || content));
-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_posts_updated_at BEFORE UPDATE ON posts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
Query Optimization
You: Эта query очень медленная, оптимизируй:
SELECT * FROM posts
WHERE author_id IN (
SELECT following_id FROM followers WHERE follower_id = 'user-id'
)
ORDER BY created_at DESC
AI: Проблема с текущей query:
- Subquery выполняется для каждой строки
- SELECT * загружает все колонки
- Отсутствует LIMIT
Оптимизированная версия:-- Используем JOIN вместо subquery
SELECT
p.id,
p.title,
p.slug,
p.excerpt,
p.featured_image,
p.author_id,
p.published_at,
p.view_count,
u.username,
u.avatar_url
FROM posts p
INNER JOIN followers f ON p.author_id = f.following_id
INNER JOIN users u ON p.author_id = u.id
WHERE f.follower_id = $1
AND p.status = 'published'
AND p.deleted_at IS NULL
ORDER BY p.published_at DESC
LIMIT 20 OFFSET $2;
-- Убедитесь что есть индекс:
CREATE INDEX IF NOT EXISTS idx_posts_author_published
ON posts(author_id, published_at DESC)
WHERE status = 'published' AND deleted_at IS NULL;
-- Для еще большей производительности, используйте материализованное представление:
CREATE MATERIALIZED VIEW user_feed AS
SELECT
f.follower_id,
p.id,
p.title,
p.slug,
p.excerpt,
p.featured_image,
p.published_at,
u.username,
u.avatar_url
FROM posts p
INNER JOIN followers f ON p.author_id = f.following_id
INNER JOIN users u ON p.author_id = u.id
WHERE p.status = 'published' AND p.deleted_at IS NULL;
CREATE INDEX idx_user_feed_follower ON user_feed(follower_id, published_at DESC);
-- Обновляйте view периодически:
REFRESH MATERIALIZED VIEW CONCURRENTLY user_feed;
-- Query становится простым:
SELECT * FROM user_feed
WHERE follower_id = $1
ORDER BY published_at DESC
LIMIT 20 OFFSET $2;
Migrations
You: Создай migration для добавления bookmarks feature
AI:-- migrations/20240115_add_bookmarks.up.sql
CREATE TABLE bookmarks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
folder_name VARCHAR(100),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, post_id)
);
CREATE INDEX idx_bookmarks_user ON bookmarks(user_id);
CREATE INDEX idx_bookmarks_post ON bookmarks(post_id);
CREATE INDEX idx_bookmarks_folder ON bookmarks(user_id, folder_name);
-- Add bookmark count to posts
ALTER TABLE posts ADD COLUMN bookmark_count INTEGER DEFAULT 0;
-- Function to update bookmark count
CREATE OR REPLACE FUNCTION update_post_bookmark_count()
RETURNS TRIGGER AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
UPDATE posts SET bookmark_count = bookmark_count + 1 WHERE id = NEW.post_id;
ELSIF (TG_OP = 'DELETE') THEN
UPDATE posts SET bookmark_count = bookmark_count - 1 WHERE id = OLD.post_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_bookmark_count
AFTER INSERT OR DELETE ON bookmarks
FOR EACH ROW EXECUTE FUNCTION update_post_bookmark_count();
-- migrations/20240115_add_bookmarks.down.sql
DROP TRIGGER IF EXISTS trg_bookmark_count ON bookmarks;
DROP FUNCTION IF EXISTS update_post_bookmark_count();
ALTER TABLE posts DROP COLUMN IF EXISTS bookmark_count;
DROP INDEX IF EXISTS idx_bookmarks_folder;
DROP INDEX IF EXISTS idx_bookmarks_post;
DROP INDEX IF EXISTS idx_bookmarks_user;
DROP TABLE IF EXISTS bookmarks;
11.4. API и веб-сервисы
REST API Client Generation
You: Создай TypeScript client для этого API:
[Вставьте OpenAPI spec или пример endpoints]
AI:// api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
export interface ApiConfig {
baseURL: string;
timeout?: number;
headers?: Record<string, string>;
}
export interface User {
id: string;
email: string;
username: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
createdAt: string;
}
export interface Post {
id: string;
title: string;
slug: string;
content: string;
excerpt?: string;
authorId: string;
author?: User;
status: 'draft' | 'published' | 'archived';
publishedAt?: string;
viewCount: number;
createdAt: string;
updatedAt: string;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: any;
};
}
class ApiClient {
private client: AxiosInstance;
constructor(config: ApiConfig) {
this.client = axios.create({
baseURL: config.baseURL,
timeout: config.timeout || 10000,
headers: {
'Content-Type': 'application/json',
...config.headers
}
});
// Request interceptor for auth token
this.client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling
this.client.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
return Promise.reject(error.response?.data || error);
}
);
}
// Users API
users = {
getAll: (params?: { page?: number; limit?: number }) =>
this.client.get<ApiResponse<PaginatedResponse<User>>>('/users', { params }),
getById: (id: string) =>
this.client.get<ApiResponse<User>>(`/users/${id}`),
create: (data: Partial<User>) =>
this.client.post<ApiResponse<User>>('/users', data),
update: (id: string, data: Partial<User>) =>
this.client.put<ApiResponse<User>>(`/users/${id}`, data),
delete: (id: string) =>
this.client.delete<ApiResponse<void>>(`/users/${id}`)
};
// Posts API
posts = {
getAll: (params?: {
page?: number;
limit?: number;
status?: string;
authorId?: string;
}) =>
this.client.get<ApiResponse<PaginatedResponse<Post>>>('/posts', { params }),
getById: (id: string) =>
this.client.get<ApiResponse<Post>>(`/posts/${id}`),
getBySlug: (slug: string) =>
this.client.get<ApiResponse<Post>>(`/posts/slug/${slug}`),
create: (data: Partial<Post>) =>
this.client.post<ApiResponse<Post>>('/posts', data),
update: (id: string, data: Partial<Post>) =>
this.client.put<ApiResponse<Post>>(`/posts/${id}`, data),
delete: (id: string) =>
this.client.delete<ApiResponse<void>>(`/posts/${id}`),
publish: (id: string) =>
this.client.post<ApiResponse<Post>>(`/posts/${id}/publish`),
unpublish: (id: string) =>
this.client.post<ApiResponse<Post>>(`/posts/${id}/unpublish`)
};
// Auth API
auth = {
login: (credentials: { email: string; password: string }) =>
this.client.post<ApiResponse<{ user: User; token: string }>>('/auth/login', credentials),
register: (data: { email: string; password: string; username: string }) =>
this.client.post<ApiResponse<{ user: User; token: string }>>('/auth/register', data),
logout: () =>
this.client.post<ApiResponse<void>>('/auth/logout'),
refreshToken: () =>
this.client.post<ApiResponse<{ token: string }>>('/auth/refresh'),
getCurrentUser: () =>
this.client.get<ApiResponse<User>>('/auth/me')
};
}
// Singleton instance
const api = new ApiClient({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:3000/api'
});
export default api;
// Usage example:
// import api from './api/client';
//
// const users = await api.users.getAll({ page: 1, limit: 10 });
// const post = await api.posts.getById('post-id');
GraphQL Client
You: Создай Apollo Client setup для GraphQL API
AI:// apollo/client.ts
import {
ApolloClient,
InMemoryCache,
HttpLink,
ApolloLink,
from
} from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { setContext } from '@apollo/client/link/context';
// HTTP Link
const httpLink = new HttpLink({
uri: process.env.REACT_APP_GRAPHQL_URL || 'http://localhost:4000/graphql'
});
// Auth Link - adds authorization header
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('auth_token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : ''
}
};
});
// Error Link - handles errors globally
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
console.error(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
);
// Handle authentication errors
if (extensions?.code === 'UNAUTHENTICATED') {
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
});
}
if (networkError) {
console.error(`[Network error]: ${networkError}`);
}
});
// Logging Link (development only)
const loggingLink = new ApolloLink((operation, forward) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[GraphQL] ${operation.operationName}`);
}
return forward(operation);
});
// Create Apollo Client
const client = new ApolloClient({
link: from([loggingLink, errorLink, authLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Pagination support
posts: {
keyArgs: ['filter'],
merge(existing = { nodes: [] }, incoming) {
return {
...incoming,
nodes: [...existing.nodes, ...incoming.nodes]
};
}
}
}
}
}
}),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network'
}
}
});
export default client;
// queries.ts
import { gql } from '@apollo/client';
export const GET_POSTS = gql`
query GetPosts($page: Int, $limit: Int, $filter: PostFilter) {
posts(page: $page, limit: $limit, filter: $filter) {
nodes {
id
title
slug
excerpt
publishedAt
author {
id
username
avatarUrl
}
categories {
id
name
slug
}
}
pageInfo {
total
page
pageSize
totalPages
hasNextPage
}
}
}
`;
export const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
id
title
slug
content
excerpt
featuredImage
publishedAt
viewCount
author {
id
username
avatarUrl
bio
}
categories {
id
name
slug
}
tags {
id
name
slug
}
comments {
id
content
author {
id
username
avatarUrl
}
createdAt
}
}
}
`;
export const CREATE_POST = gql`
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
slug
status
createdAt
}
}
`;
// hooks.ts
import { useQuery, useMutation } from '@apollo/client';
import { GET_POSTS, GET_POST, CREATE_POST } from './queries';
export function usePosts(variables?: any) {
return useQuery(GET_POSTS, { variables });
}
export function usePost(id: string) {
return useQuery(GET_POST, {
variables: { id },
skip: !id
});
}
export function useCreatePost() {
return useMutation(CREATE_POST, {
update(cache, { data: { createPost } }) {
// Update cache after creation
cache.modify({
fields: {
posts(existing = { nodes: [] }) {
return {
...existing,
nodes: [createPost, ...existing.nodes]
};
}
}
});
}
});
}
// Usage in component:
// const { data, loading, error } = usePosts({ page: 1, limit: 10 });
// const [createPost] = useCreatePost();
11.5. Тестирование и quality assurance
Unit Test Generation
You: Создай unit тесты для этой функции:
function calculateCartTotal(items, discountCode) {
let subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
if (discountCode === 'SAVE10') {
subtotal *= 0.9;
} else if (discountCode === 'SAVE20') {
subtotal *= 0.8;
}
const tax = subtotal * 0.08;
return subtotal + tax;
}
AI:// calculateCartTotal.test.ts
import { calculateCartTotal } from './calculateCartTotal';
describe('calculateCartTotal', () => {
describe('without discount code', () => {
it('should calculate total with tax for single item', () => {
const items = [{ price: 100, quantity: 1 }];
const total = calculateCartTotal(items);
expect(total).toBe(108); // 100 + 8% tax
});
it('should calculate total for multiple items', () => {
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 3 }
];
const total = calculateCartTotal(items);
// Subtotal: (100*2) + (50*3) = 350
// Tax: 350 * 0.08 = 28
// Total: 378
expect(total).toBe(378);
});
it('should handle zero quantity', () => {
const items = [{ price: 100, quantity: 0 }];
const total = calculateCartTotal(items);
expect(total).toBe(0);
});
it('should handle empty cart', () => {
const items = [];
const total = calculateCartTotal(items);
expect(total).toBe(0);
});
});
describe('with SAVE10 discount code', () => {
it('should apply 10% discount', () => {
const items = [{ price: 100, quantity: 1 }];
const total = calculateCartTotal(items, 'SAVE10');
// Subtotal: 100
// After discount: 90
// Tax: 90 * 0.08 = 7.2
// Total: 97.2
expect(total).toBe(97.2);
});
it('should apply discount to multiple items', () => {
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 2 }
];
const total = calculateCartTotal(items, 'SAVE10');
// Subtotal: 300
// After discount: 270
// Tax: 270 * 0.08 = 21.6
// Total: 291.6
expect(total).toBe(291.6);
});
});
describe('with SAVE20 discount code', () => {
it('should apply 20% discount', () => {
const items = [{ price: 100, quantity: 1 }];
const total = calculateCartTotal(items, 'SAVE20');
// Subtotal: 100
// After discount: 80
// Tax: 80 * 0.08 = 6.4
// Total: 86.4
expect(total).toBe(86.4);
});
});
describe('with invalid discount code', () => {
it('should not apply discount for unknown code', () => {
const items = [{ price: 100, quantity: 1 }];
const total = calculateCartTotal(items, 'INVALID');
expect(total).toBe(108); // No discount applied
});
it('should not apply discount for null code', () => {
const items = [{ price: 100, quantity: 1 }];
const total = calculateCartTotal(items, null);
expect(total).toBe(108);
});
});
describe('edge cases', () => {
it('should handle decimal prices', () => {
const items = [{ price: 19.99, quantity: 1 }];
const total = calculateCartTotal(items);
expect(total).toBeCloseTo(21.59, 2);
});
it('should handle large quantities', () => {
const items = [{ price: 10, quantity: 1000 }];
const total = calculateCartTotal(items);
expect(total).toBe(10800);
});
it('should handle very small prices', () => {
const items = [{ price: 0.01, quantity: 1 }];
const total = calculateCartTotal(items);
expect(total).toBeCloseTo(0.0108, 4);
});
});
});
Integration Test Generation
You: Создай integration тесты для API endpoint POST /api/users
AI:// users.integration.test.ts
import request from 'supertest';
import app from '../app';
import { db } from '../database';
describe('POST /api/users', () => {
beforeAll(async () => {
// Setup test database
await db.migrate.latest();
});
afterAll(async () => {
// Cleanup
await db.destroy();
});
beforeEach(async () => {
// Clear users table before each test
await db('users').truncate();
});
describe('successful creation', () => {
it('should create user with valid data', async () => {
const userData = {
email: 'test@example.com',
username: 'testuser',
password: 'SecurePass123!'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toMatchObject({
success: true,
data: {
email: userData.email,
username: userData.username
}
});
expect(response.body.data).toHaveProperty('id');
expect(response.body.data).not.toHaveProperty('password');
// Verify user exists in database
const userInDb = await db('users')
.where({ email: userData.email })
.first();
expect(userInDb).toBeDefined();
expect(userInDb.password_hash).not.toBe(userData.password); // Should be hashed
});
it('should return authentication token', async () => {
const userData = {
email: 'test@example.com',
username: 'testuser',
password: 'SecurePass123!'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body.data).toHaveProperty('token');
expect(typeof response.body.data.token).toBe('string');
expect(response.body.data.token.length).toBeGreaterThan(20);
});
});
describe('validation errors', () => {
it('should reject invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
username: 'testuser',
password: 'SecurePass123!'
})
.expect(400);
expect(response.body.success).toBe(false);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.message).toContain('email');
});
it('should reject short password', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
username: 'testuser',
password: 'short'
})
.expect(400);
expect(response.body.error.message).toContain('password');
});
it('should reject missing required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com'
})
.expect(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
});
});
describe('duplicate handling', () => {
beforeEach(async () => {
// Create existing user
await request(app)
.post('/api/users')
.send({
email: 'existing@example.com',
username: 'existing',
password: 'SecurePass123!'
});
});
it('should reject duplicate email', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'existing@example.com',
username: 'newuser',
password: 'SecurePass123!'
})
.expect(409);
expect(response.body.error.code).toBe('DUPLICATE_EMAIL');
});
it('should reject duplicate username', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'new@example.com',
username: 'existing',
password: 'SecurePass123!'
})
.expect(409);
expect(response.body.error.code).toBe('DUPLICATE_USERNAME');
});
});
describe('security', () => {
it('should hash password before storing', async () => {
const password = 'SecurePass123!';
await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
username: 'testuser',
password
})
.expect(201);
const userInDb = await db('users')
.where({ email: 'test@example.com' })
.first();
expect(userInDb.password_hash).not.toBe(password);
expect(userInDb.password_hash).toMatch(/^\$2[aby]\$.{56}$/); // bcrypt format
});
it('should not expose password in response', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
username: 'testuser',
password: 'SecurePass123!'
})
.expect(201);
expect(response.body.data).not.toHaveProperty('password');
expect(response.body.data).not.toHaveProperty('password_hash');
});
});
});
E2E Test with Playwright
You: Создай E2E тест для user registration flow
AI:// e2e/user-registration.spec.ts
import { test, expect } from '@playwright/test';
test.describe('User Registration', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register');
});
test('should successfully register new user', async ({ page }) => {
// Fill registration form
await page.fill('[name="email"]', 'newuser@example.com');
await page.fill('[name="username"]', 'newuser123');
await page.fill('[name="password"]', 'SecurePassword123!');
await page.fill('[name="confirmPassword"]', 'SecurePassword123!');
// Submit form
await page.click('button[type="submit"]');
// Should redirect to dashboard
await expect(page).toHaveURL('/dashboard');
// Should show welcome message
await expect(page.locator('.welcome-message')).toContainText('Welcome, newuser123');
// Should display user info
await expect(page.locator('.user-email')).toContainText('newuser@example.com');
});
test('should show validation errors for invalid data', async ({ page }) => {
// Invalid email
await page.fill('[name="email"]', 'invalid-email');
await page.fill('[name="username"]', 'user');
await page.fill('[name="password"]', 'pass');
await page.click('button[type="submit"]');
// Should show error messages
await expect(page.locator('.error-email')).toContainText('Invalid email format');
await expect(page.locator('.error-password')).toContainText('Password must be at least 8 characters');
});
test('should show error for duplicate email', async ({ page }) => {
// Try to register with existing email
await page.fill('[name="email"]', 'existing@example.com');
await page.fill('[name="username"]', 'newuser');
await page.fill('[name="password"]', 'SecurePassword123!');
await page.fill('[name="confirmPassword"]', 'SecurePassword123!');
await page.click('button[type="submit"]');
// Should show error
await expect(page.locator('.error-message')).toContainText('Email already exists');
// Should remain on registration page
await expect(page).toHaveURL('/register');
});
test('should validate password confirmation', async ({ page }) => {
await page.fill('[name="password"]', 'SecurePassword123!');
await page.fill('[name="confirmPassword"]', 'DifferentPassword123!');
await page.click('button[type="submit"]');
await expect(page.locator('.error-confirmPassword')).toContainText('Passwords must match');
});
test('should toggle password visibility', async ({ page }) => {
const passwordInput = page.locator('[name="password"]');
const toggleButton = page.locator('.toggle-password-visibility');
// Password should be hidden by default
await expect(passwordInput).toHaveAttribute('type', 'password');
// Click toggle
await toggleButton.click();
// Password should be visible
await expect(passwordInput).toHaveAttribute('type', 'text');
// Click again to hide
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'password');
});
test('should navigate to login page', async ({ page }) => {
await page.click('text=Already have an account? Login');
await expect(page).toHaveURL('/login');
});
test('should be accessible', async ({ page }) => {
// Check for proper labels
await expect(page.locator('label[for="email"]')).toContainText('Email');
await expect(page.locator('label[for="username"]')).toContainText('Username');
await expect(page.locator('label[for="password"]')).toContainText('Password');
// Check for ARIA attributes
const emailInput = page.locator('[name="email"]');
await expect(emailInput).toHaveAttribute('aria-label', 'Email address');
await expect(emailInput).toHaveAttribute('aria-required', 'true');
// Check keyboard navigation
await page.keyboard.press('Tab'); // Focus email
await expect(emailInput).toBeFocused();
await page.keyboard.press('Tab'); // Focus username
await expect(page.locator('[name="username"]')).toBeFocused();
});
});