Merge branch 'refactor2'
This commit is contained in:
+27
-7
@@ -19,8 +19,8 @@
|
|||||||
- ✅ 3.3 AdminOrdersPage, AdminUsersPage (shared AdminDialog + AdminTable)
|
- ✅ 3.3 AdminOrdersPage, AdminUsersPage (shared AdminDialog + AdminTable)
|
||||||
- ✅ 5.1 fastify.decorate вместо параметров
|
- ✅ 5.1 fastify.decorate вместо параметров
|
||||||
- ✅ 5.2 Валидация через Fastify Schema
|
- ✅ 5.2 Валидация через Fastify Schema
|
||||||
- ⬜ 6.1 Error Boundary
|
- ✅ 6.1 Error Boundary
|
||||||
- ⬜ 6.2 Тесты
|
- ✅ 6.2 Тесты
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -122,21 +122,41 @@
|
|||||||
|
|
||||||
## 6. Инфраструктура (низкий приоритет)
|
## 6. Инфраструктура (низкий приоритет)
|
||||||
|
|
||||||
### 6.1 Error Boundary ⬜
|
### 6.1 Error Boundary ✅
|
||||||
### 6.2 Тесты ⬜
|
|
||||||
|
Создан `shared/ui/ErrorBoundary/ErrorBoundary.tsx` — class-компонент с `getDerivedStateFromError` / `componentDidCatch`.
|
||||||
|
- Отображает MUI `Alert` с заголовком «Что-то пошло не так» и кнопкой «Попробовать снова».
|
||||||
|
- Поддерживает кастомный `fallback` и колбэк `onError`.
|
||||||
|
- Интегрирован в `App.tsx`: `<ErrorBoundary><AppRoutes /></ErrorBoundary>`.
|
||||||
|
|
||||||
|
### 6.2 Тесты ✅
|
||||||
|
|
||||||
|
**Клиент (vitest + jsdom + @testing-library/react):**
|
||||||
|
- `shared/lib/__tests__/get-error-message.test.ts` — 4 теста
|
||||||
|
- `shared/lib/__tests__/format-price.test.ts` — 3 теста
|
||||||
|
- `shared/lib/__tests__/group-orders-by-status.test.ts` — 3 теста
|
||||||
|
- `shared/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx` — 4 теста (рендер, падение, кастомный fallback, сброс)
|
||||||
|
|
||||||
|
**Сервер (vitest):**
|
||||||
|
- `src/lib/__tests__/escape-html.test.js` — 4 теста
|
||||||
|
- `src/lib/__tests__/order-status.test.js` — 9 тестов (`canTransitionAdminOrderStatus`)
|
||||||
|
|
||||||
|
**Команды:** `npm test` (vitest run), `npm run test:watch` (vitest).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Сводка изменений
|
## Сводка изменений
|
||||||
|
|
||||||
| Область | Файлов создано | Файлов изменено |
|
| Область | Файлов создано | Файлов изменено |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Server routes | 0 | 4 (декораты + схемы) |
|
| Server routes | 0 | 4 (декораты + схемы) |
|
||||||
| Client pages | 3 | 2 (HomePage, AdminOrdersPage, AdminUsersPage) |
|
| Client pages | 3 | 2 (HomePage, AdminOrdersPage, AdminUsersPage) |
|
||||||
| Client entities | 6 | 2 (barrel, GalleryGrid, model types) |
|
| Client entities | 6 | 2 (barrel, GalleryGrid, model types) |
|
||||||
| Client features | 3 | 2 (map-geocoding, AddressMapPicker) |
|
| Client features | 3 | 2 (map-geocoding, AddressMapPicker) |
|
||||||
| Client shared/ui | 2 | 0 (AdminDialog, AdminTable) |
|
| Client shared/ui | 3 | 0 (AdminDialog, AdminTable, ErrorBoundary) |
|
||||||
| Client app config | 0 | 2 (vite.config, tsconfig) |
|
| Client app config | 0 | 2 (vite.config, tsconfig) |
|
||||||
|
| Client tests | 4 | 0 (vitest config, setup, 3 test files) |
|
||||||
|
| Server tests | 2 | 0 (vitest config, 2 test files) |
|
||||||
| Shared constants | 8 | 0 (order-status, delivery-carrier, etc.) |
|
| Shared constants | 8 | 0 (order-status, delivery-carrier, etc.) |
|
||||||
| Server constants | 0 | 3 (order-status, delivery-carrier, upload-limits) |
|
| Server constants | 0 | 3 (order-status, delivery-carrier, upload-limits) |
|
||||||
| **Итого** | **22** | **15** |
|
| **Итого** | **29** | **15** |
|
||||||
|
|||||||
Generated
+2231
-2
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -10,7 +10,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier . --write --ignore-unknown",
|
"format": "prettier . --write --ignore-unknown",
|
||||||
"format:check": "prettier . --check --ignore-unknown"
|
"format:check": "prettier . --check --ignore-unknown",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
@@ -34,6 +36,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -49,9 +54,11 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { AppProviders } from '@/app/providers/AppProviders'
|
import { AppProviders } from '@/app/providers/AppProviders'
|
||||||
import { AppRoutes } from '@/app/routes'
|
import { AppRoutes } from '@/app/routes'
|
||||||
|
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<AppProviders>
|
<AppProviders>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<ErrorBoundary>
|
||||||
|
<AppRoutes />
|
||||||
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppProviders>
|
</AppProviders>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { formatPriceRub } from '../format-price'
|
||||||
|
|
||||||
|
describe('formatPriceRub', () => {
|
||||||
|
it('formats cents to rubles', () => {
|
||||||
|
const result = formatPriceRub(10000)
|
||||||
|
expect(result).toContain('100')
|
||||||
|
expect(result).toContain('₽')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats zero', () => {
|
||||||
|
const result = formatPriceRub(0)
|
||||||
|
expect(result).toContain('0')
|
||||||
|
expect(result).toContain('₽')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds to integer rubles', () => {
|
||||||
|
const result = formatPriceRub(10050)
|
||||||
|
expect(result).toContain('101')
|
||||||
|
expect(result).toContain('₽')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { getErrorMessage } from '../get-error-message'
|
||||||
|
|
||||||
|
describe('getErrorMessage', () => {
|
||||||
|
it('returns error message for Error instance', () => {
|
||||||
|
expect(getErrorMessage(new Error('test error'))).toBe('test error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for non-Error', () => {
|
||||||
|
expect(getErrorMessage('string error')).toBe('Произошла ошибка')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns custom fallback when provided', () => {
|
||||||
|
expect(getErrorMessage(null, 'custom')).toBe('custom')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for Error with empty message', () => {
|
||||||
|
expect(getErrorMessage(new Error(''), 'empty')).toBe('empty')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { groupOrdersByStatus } from '../group-orders-by-status'
|
||||||
|
|
||||||
|
const STATUSES = ['PENDING', 'PAID', 'DONE'] as const
|
||||||
|
|
||||||
|
describe('groupOrdersByStatus', () => {
|
||||||
|
it('groups and sorts orders by status', () => {
|
||||||
|
const orders = [
|
||||||
|
{ status: 'PAID', createdAt: '2024-01-02T10:00:00Z' },
|
||||||
|
{ status: 'PENDING', createdAt: '2024-01-01T10:00:00Z' },
|
||||||
|
{ status: 'PAID', createdAt: '2024-01-03T10:00:00Z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = groupOrdersByStatus(orders, STATUSES)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].status).toBe('PENDING')
|
||||||
|
expect(result[1].status).toBe('PAID')
|
||||||
|
expect(result[1].items).toHaveLength(2)
|
||||||
|
expect(result[1].items[0].createdAt).toBe('2024-01-03T10:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for no matching orders', () => {
|
||||||
|
const result = groupOrdersByStatus([], STATUSES)
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores unknown statuses', () => {
|
||||||
|
const orders = [{ status: 'UNKNOWN', createdAt: '2024-01-01T10:00:00Z' }]
|
||||||
|
const result = groupOrdersByStatus(orders, STATUSES)
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||||
|
import Alert from '@mui/material/Alert'
|
||||||
|
import AlertTitle from '@mui/material/AlertTitle'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
onError?: (error: Error, errorInfo: ErrorInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||||
|
this.props.onError?.(error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = (): void => {
|
||||||
|
this.setState({ error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.error) {
|
||||||
|
if (this.props.fallback) return this.props.fallback
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Box sx={{ maxWidth: 480 }}>
|
||||||
|
<Alert severity="error">
|
||||||
|
<AlertTitle>Что-то пошло не так</AlertTitle>
|
||||||
|
{this.state.error.message || 'Произошла непредвиденная ошибка.'}
|
||||||
|
</Alert>
|
||||||
|
<Button onClick={this.handleReset} variant="outlined" sx={{ mt: 2 }}>
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ErrorBoundary } from '../ErrorBoundary'
|
||||||
|
|
||||||
|
const Bomb = ({ shouldThrow }: { shouldThrow?: boolean }) => {
|
||||||
|
if (shouldThrow) throw new Error('boom')
|
||||||
|
return <div>safe</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
it('renders children when no error', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div>hello</div>
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('hello')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders fallback on error', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Bomb shouldThrow />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Что-то пошло не так')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders custom fallback when provided', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary fallback={<div>custom error ui</div>}>
|
||||||
|
<Bomb shouldThrow />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('custom error ui')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets after clicking retry button when children are fixed', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Bomb shouldThrow />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Что-то пошло не так')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Bomb />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Попробовать снова'))
|
||||||
|
|
||||||
|
expect(screen.getByText('safe')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ErrorBoundary } from './ErrorBoundary'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||||
|
import viteConfig from './vite.config'
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: './src/testing/setup.ts',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
Generated
+1565
-1
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -13,7 +13,9 @@
|
|||||||
"db:seed:test": "node --env-file=.dev_env prisma/seed.js",
|
"db:seed:test": "node --env-file=.dev_env prisma/seed.js",
|
||||||
"db:reset:test": "node --env-file=.dev_env ./node_modules/prisma/build/index.js migrate reset --force",
|
"db:reset:test": "node --env-file=.dev_env ./node_modules/prisma/build/index.js migrate reset --force",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:studio:test": "node --env-file=.dev_env ./node_modules/prisma/build/index.js studio"
|
"db:studio:test": "node --env-file=.dev_env ./node_modules/prisma/build/index.js studio",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "node prisma/seed.js"
|
"seed": "node prisma/seed.js"
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"nodemailer": "^8.0.7"
|
"nodemailer": "^8.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prisma": "5.22.0"
|
"prisma": "5.22.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { escapeHtml } from '../escape-html.js'
|
||||||
|
|
||||||
|
describe('escapeHtml', () => {
|
||||||
|
it('escapes & < > "', () => {
|
||||||
|
expect(escapeHtml('&<>"')).toBe('&<>"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for null/undefined', () => {
|
||||||
|
expect(escapeHtml(null)).toBe('')
|
||||||
|
expect(escapeHtml(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes safe text through', () => {
|
||||||
|
expect(escapeHtml('hello world')).toBe('hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes mixed content', () => {
|
||||||
|
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
|
||||||
|
'<script>alert("xss")</script>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canTransitionAdminOrderStatus } from '../order-status.js'
|
||||||
|
|
||||||
|
describe('canTransitionAdminOrderStatus', () => {
|
||||||
|
const delivery = { deliveryType: 'delivery' }
|
||||||
|
const pickup = { deliveryType: 'pickup' }
|
||||||
|
|
||||||
|
it('DRAFT → PENDING_PAYMENT', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PENDING_PAYMENT')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DRAFT → CANCELLED', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'CANCELLED')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DRAFT cannot skip to PAID', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PAID')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PAID → IN_PROGRESS', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'PAID', ...delivery }, 'IN_PROGRESS')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (delivery) → SHIPPED', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'SHIPPED')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (pickup) → READY_FOR_PICKUP', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...pickup }, 'READY_FOR_PICKUP')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (delivery) cannot go to READY_FOR_PICKUP', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'READY_FOR_PICKUP')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DONE allows no transitions', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'CANCELLED')).toBe(false)
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'PAID')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('same status returns true', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'DRAFT')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user