ываыв
This commit is contained in:
@@ -21,6 +21,14 @@ export function SseProvider() {
|
|||||||
const es = createEventStream(token)
|
const es = createEventStream(token)
|
||||||
sourceRef.current = es
|
sourceRef.current = es
|
||||||
|
|
||||||
|
function invalidateOrderQueries(orderId: unknown) {
|
||||||
|
if (!orderId) return
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvent(eventName: string) {
|
function handleEvent(eventName: string) {
|
||||||
return function (event: MessageEvent) {
|
return function (event: MessageEvent) {
|
||||||
try {
|
try {
|
||||||
@@ -31,22 +39,13 @@ export function SseProvider() {
|
|||||||
case 'message:new':
|
case 'message:new':
|
||||||
queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||||
if (orderId) {
|
invalidateOrderQueries(orderId)
|
||||||
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'order:statusChanged':
|
case 'order:statusChanged':
|
||||||
if (orderId) {
|
invalidateOrderQueries(orderId)
|
||||||
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'order:updated':
|
case 'order:updated':
|
||||||
if (orderId) {
|
invalidateOrderQueries(orderId)
|
||||||
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'order:new':
|
case 'order:new':
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
|||||||
@@ -108,7 +108,9 @@ describe('SseProvider', () => {
|
|||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
|
||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
|
||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o1'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('invalidates order queries on order:statusChanged', () => {
|
it('invalidates order queries on order:statusChanged', () => {
|
||||||
@@ -117,7 +119,9 @@ describe('SseProvider', () => {
|
|||||||
const handler = mockEventHandlers['order:statusChanged']
|
const handler = mockEventHandlers['order:statusChanged']
|
||||||
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
|
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
|
||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
|
||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o2'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('invalidates order queries on order:updated', () => {
|
it('invalidates order queries on order:updated', () => {
|
||||||
@@ -126,7 +130,9 @@ describe('SseProvider', () => {
|
|||||||
const handler = mockEventHandlers['order:updated']
|
const handler = mockEventHandlers['order:updated']
|
||||||
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
|
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
|
||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
|
||||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o3'] })
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('invalidates admin queries on order:new', () => {
|
it('invalidates admin queries on order:new', () => {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'user' : 'admin'} avatar={avatarNode}>
|
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
@@ -29,6 +30,12 @@ vi.mock('@/shared/ui/RichTextMessageEditor.lazy', () => ({
|
|||||||
}) => <textarea aria-label="Ответ админа" value={value} onChange={(e) => onChange(e.target.value)} />,
|
}) => <textarea aria-label="Ответ админа" value={value} onChange={(e) => onChange(e.target.value)} />,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/ui/ChatMessageBubble', () => ({
|
||||||
|
ChatMessageBubble: ({ authorType, children }: { authorType: 'admin' | 'user'; children: ReactNode }) => (
|
||||||
|
<div data-testid={`chat-message-${authorType}`}>{children}</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
const setAdminOrderStatusMock = vi.mocked(setAdminOrderStatus)
|
const setAdminOrderStatusMock = vi.mocked(setAdminOrderStatus)
|
||||||
|
|
||||||
function createDetail(overrides?: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
|
function createDetail(overrides?: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
|
||||||
@@ -175,4 +182,30 @@ describe('OrderDetailContent quick status transitions', () => {
|
|||||||
await user.click(paidButton)
|
await user.click(paidButton)
|
||||||
expect(setAdminOrderStatusMock).toHaveBeenCalledTimes(2)
|
expect(setAdminOrderStatusMock).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('передает фактический authorType в пузырь сообщения', () => {
|
||||||
|
renderComponent(
|
||||||
|
createDetail({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'message-admin',
|
||||||
|
authorType: 'admin',
|
||||||
|
text: 'Ответ администратора',
|
||||||
|
attachmentUrl: null,
|
||||||
|
createdAt: '2026-05-28T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message-user',
|
||||||
|
authorType: 'user',
|
||||||
|
text: 'Сообщение покупателя',
|
||||||
|
attachmentUrl: null,
|
||||||
|
createdAt: '2026-05-28T10:01:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('chat-message-admin')).toHaveTextContent('Админ (вы)')
|
||||||
|
expect(screen.getByTestId('chat-message-user')).toHaveTextContent('Пользователь')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canTransitionOrderStatus, getAdminNextOrderStatuses } from '../order'
|
||||||
|
|
||||||
|
describe('client order status helpers', () => {
|
||||||
|
it('returns delivery-specific next statuses', () => {
|
||||||
|
expect(getAdminNextOrderStatuses('IN_PROGRESS', 'delivery')).toEqual(['SHIPPED', 'CANCELLED'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns pickup-specific next statuses', () => {
|
||||||
|
expect(getAdminNextOrderStatuses('IN_PROGRESS', 'pickup')).toEqual(['READY_FOR_PICKUP', 'CANCELLED'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks pickup transition without falling back to delivery rules', () => {
|
||||||
|
expect(canTransitionOrderStatus('IN_PROGRESS', 'READY_FOR_PICKUP', 'pickup')).toBe(true)
|
||||||
|
expect(canTransitionOrderStatus('IN_PROGRESS', 'SHIPPED', 'pickup')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,7 +11,7 @@ export function getAdminNextOrderStatuses(status: string, deliveryType: 'deliver
|
|||||||
return sharedGetNextAdminStatuses(status, deliveryType) as OrderStatus[]
|
return sharedGetNextAdminStatuses(status, deliveryType) as OrderStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canTransitionOrderStatus(from: string, to: string): boolean {
|
export function canTransitionOrderStatus(from: string, to: string, deliveryType: 'delivery' | 'pickup'): boolean {
|
||||||
if (from === to) return true
|
if (from === to) return true
|
||||||
return getAdminNextOrderStatuses(from, 'delivery').includes(to as OrderStatus)
|
return getAdminNextOrderStatuses(from, deliveryType).includes(to as OrderStatus)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Аудит заказов, оплаты и админки заказов
|
||||||
|
|
||||||
|
**Дата:** 2026-05-28
|
||||||
|
**Scope:** `orders/payments/admin-orders`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- P0: не найдено в первой итерации.
|
||||||
|
- P1: исправлены SSE/queryKey для админской деталки, authorType админского чата, summary внимания и guard оплаты до подтверждения доставки.
|
||||||
|
- P2: добавлены focused tests для найденных дефектов.
|
||||||
|
- P3: рефакторинги оставлены в backlog.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P1-001: SSE инвалидирует несуществующий queryKey админской деталки
|
||||||
|
|
||||||
|
**Статус:** fixed
|
||||||
|
**Код:** `client/src/app/providers/SseProvider.tsx`
|
||||||
|
**Тест:** `client/src/app/providers/__tests__/SseProvider.test.tsx`
|
||||||
|
**Симптом:** события `message:new`, `order:statusChanged`, `order:updated` инвалидируют `['admin', 'orders', orderId]`, но деталка использует `['admin', 'orders', 'detail', selectedId]`.
|
||||||
|
**Решение:** добавить инвалидацию `['admin', 'orders', 'detail', orderId]`, списка и summary для order events.
|
||||||
|
|
||||||
|
### P1-002: Админский чат передает инвертированный authorType в ChatMessageBubble
|
||||||
|
|
||||||
|
**Статус:** fixed
|
||||||
|
**Код:** `client/src/features/order-detail/ui/OrderDetailContent.tsx`
|
||||||
|
**Тест:** `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
**Симптом:** для `m.authorType === 'admin'` компонент получает `authorType="user"`.
|
||||||
|
**Решение:** передавать фактический `authorType`: admin-message как `admin`, user-message как `user`.
|
||||||
|
|
||||||
|
### P1-003: Admin summary считает все PENDING_PAYMENT как attention
|
||||||
|
|
||||||
|
**Статус:** fixed
|
||||||
|
**Код:** `server/src/routes/api/admin-orders.js`
|
||||||
|
**Тест:** `server/src/routes/api/__tests__/admin-orders.test.js`
|
||||||
|
**Симптом:** `attentionCount` считает все `PENDING_PAYMENT`, включая заказы, где цена уже подтверждена.
|
||||||
|
**Решение:** считать только `PENDING_PAYMENT + delivery + deliveryFeeLocked=false`.
|
||||||
|
|
||||||
|
### P1-004: Admin может перевести delivery-заказ в PAID до подтверждения цены
|
||||||
|
|
||||||
|
**Статус:** fixed
|
||||||
|
**Код:** `server/src/routes/api/admin-orders.js`
|
||||||
|
**Тест:** `server/src/routes/api/__tests__/admin-orders.test.js`
|
||||||
|
**Симптом:** статусный guard разрешает `PENDING_PAYMENT -> PAID` без проверки `deliveryFeeLocked`.
|
||||||
|
**Решение:** для `next === 'PAID'` отклонять delivery-заказы с `deliveryFeeLocked === false` кодом 409.
|
||||||
|
|
||||||
|
## Refactor Backlog
|
||||||
|
|
||||||
|
- Проверить, нужен ли `client/src/shared/constants/order.ts::canTransitionOrderStatus`; если нужен, сделать deliveryType обязательным аргументом.
|
||||||
|
- После стабилизации route-тестов рассмотреть вынос части логики `admin-orders.js` в маленькие pure helpers.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `server npm test` — pass
|
||||||
|
- `server npm run lint` — pass, warnings only
|
||||||
|
- `server npm run format:check` — pass
|
||||||
|
- `client npm run lint` — pass, warnings only
|
||||||
|
- `client npm run format:check` — pass
|
||||||
|
- `client focused vitest` — pass
|
||||||
|
- `client npm run build` — pass, Vite chunk-size warning only
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
# Аудит заказов и оплаты Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Найти, зафиксировать тестами и исправить наиболее рискованные проблемы в потоке заказов, оплаты, статусов, доставки и админки заказов.
|
||||||
|
|
||||||
|
**Architecture:** Идем от воспроизводимых дефектов к минимальным исправлениям. Серверные инварианты закрепляем route/unit тестами рядом с существующими тестами Fastify/Vitest; клиентские рассинхроны закрепляем component/unit тестами в существующих `__tests__`. Рефакторинг делаем только там, где он убирает подтвержденную ошибку или опасное дублирование.
|
||||||
|
|
||||||
|
**Tech Stack:** Fastify, Prisma, SQLite, Vitest, React, TypeScript, React Query, MUI, FSD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Create: `docs/superpowers/audits/2026-05-28-orders-payments-audit.md` — итоговый audit report с находками, приоритетами и решениями.
|
||||||
|
- Create: `server/src/routes/api/__tests__/admin-orders.test.js` — route-тесты summary, delivery fee и admin status guard.
|
||||||
|
- Modify: `server/src/routes/api/admin-orders.js` — исправления summary и защиты смены статуса при неподтвержденной цене доставки.
|
||||||
|
- Modify: `client/src/app/providers/__tests__/SseProvider.test.tsx` — тесты на реальные query keys админской деталки и summary.
|
||||||
|
- Modify: `client/src/app/providers/SseProvider.tsx` — согласовать SSE-инвалидации с фактическими query keys.
|
||||||
|
- Modify: `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx` — тест на корректное направление сообщений в админской деталке.
|
||||||
|
- Modify: `client/src/features/order-detail/ui/OrderDetailContent.tsx` — исправить `authorType` для `ChatMessageBubble`.
|
||||||
|
- Modify: `client/src/shared/constants/order.ts` — убрать опасный delivery-only fallback из `canTransitionOrderStatus`.
|
||||||
|
- Create: `client/src/shared/constants/__tests__/order.test.ts` — тесты переходов для delivery и pickup в клиентском адаптере.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Создать audit report shell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/audits/2026-05-28-orders-payments-audit.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Создать папку для audit reports, если ее нет**
|
||||||
|
|
||||||
|
Run from repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
if (!(Test-Path "docs/superpowers/audits")) { New-Item -ItemType Directory "docs/superpowers/audits" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: папка `docs/superpowers/audits` существует.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Создать стартовый audit report**
|
||||||
|
|
||||||
|
Write `docs/superpowers/audits/2026-05-28-orders-payments-audit.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Аудит заказов, оплаты и админки заказов
|
||||||
|
|
||||||
|
**Дата:** 2026-05-28
|
||||||
|
**Scope:** `orders/payments/admin-orders`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- P0: не найдено на момент старта реализации.
|
||||||
|
- P1: проверяются status guards, delivery fee, SSE/queryKey и админский чат.
|
||||||
|
- P2: проверяется покрытие тестами ключевых контрактов.
|
||||||
|
- P3: рефакторинги фиксируются отдельно и не смешиваются с исправлениями.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P1-001: SSE инвалидирует несуществующий queryKey админской деталки
|
||||||
|
|
||||||
|
**Статус:** pending
|
||||||
|
**Код:** `client/src/app/providers/SseProvider.tsx`
|
||||||
|
**Симптом:** события `message:new`, `order:statusChanged`, `order:updated` инвалидируют `['admin', 'orders', orderId]`, но деталка использует `['admin', 'orders', 'detail', selectedId]`.
|
||||||
|
**Решение:** добавить инвалидацию `['admin', 'orders', 'detail', orderId]`, списка и summary для order events.
|
||||||
|
|
||||||
|
### P1-002: Админский чат передает инвертированный authorType в ChatMessageBubble
|
||||||
|
|
||||||
|
**Статус:** pending
|
||||||
|
**Код:** `client/src/features/order-detail/ui/OrderDetailContent.tsx`
|
||||||
|
**Симптом:** для `m.authorType === 'admin'` компонент получает `authorType="user"`.
|
||||||
|
**Решение:** передавать фактический `authorType`: admin-message как `admin`, user-message как `user`.
|
||||||
|
|
||||||
|
### P1-003: Admin summary считает все PENDING_PAYMENT как attention
|
||||||
|
|
||||||
|
**Статус:** pending
|
||||||
|
**Код:** `server/src/routes/api/admin-orders.js`
|
||||||
|
**Симптом:** `attentionCount` считает все `PENDING_PAYMENT`, включая заказы, где цена уже подтверждена.
|
||||||
|
**Решение:** считать только `PENDING_PAYMENT + delivery + deliveryFeeLocked=false`.
|
||||||
|
|
||||||
|
### P1-004: Admin может перевести delivery-заказ в PAID до подтверждения цены
|
||||||
|
|
||||||
|
**Статус:** pending
|
||||||
|
**Код:** `server/src/routes/api/admin-orders.js`
|
||||||
|
**Симптом:** статусный guard разрешает `PENDING_PAYMENT -> PAID` без проверки `deliveryFeeLocked`.
|
||||||
|
**Решение:** для `next === 'PAID'` отклонять delivery-заказы с `deliveryFeeLocked === false` кодом 409.
|
||||||
|
|
||||||
|
## Refactor Backlog
|
||||||
|
|
||||||
|
- Проверить, нужен ли `client/src/shared/constants/order.ts::canTransitionOrderStatus`; если нужен, сделать deliveryType обязательным аргументом.
|
||||||
|
- После стабилизации route-тестов рассмотреть вынос части логики `admin-orders.js` в маленькие pure helpers.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Проверить отсутствие незавершенных маркеров**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$pattern = ('TO' + 'DO|T' + 'BD|PLACE' + 'HOLDER|\?\?\?'); rg -n $pattern "docs/superpowers/audits/2026-05-28-orders-payments-audit.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no matches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Зафиксировать server defects тестами
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `server/src/routes/api/__tests__/admin-orders.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Написать failing route tests для summary и PAID guard**
|
||||||
|
|
||||||
|
Create `server/src/routes/api/__tests__/admin-orders.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { prisma } from '../../../lib/prisma.js'
|
||||||
|
import { registerAdminOrderRoutes } from '../admin-orders.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
const ADMIN_EMAIL = `admin-orders-${Date.now()}@example.com`
|
||||||
|
const USER_EMAIL = `admin-orders-user-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
let app
|
||||||
|
let adminUser
|
||||||
|
let buyer
|
||||||
|
|
||||||
|
async function signToken(user) {
|
||||||
|
return app.jwt.sign({ sub: user.id, email: user.email })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const fastify = Fastify({ logger: false })
|
||||||
|
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||||
|
fastify.decorate('eventBus', { emit: vi.fn() })
|
||||||
|
fastify.decorate('verifyAdmin', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
if (request.user.email !== ADMIN_EMAIL) {
|
||||||
|
return reply.code(401).send({ error: 'Admin only' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await registerAdminOrderRoutes(fastify)
|
||||||
|
await fastify.ready()
|
||||||
|
return fastify
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrder(data = {}) {
|
||||||
|
return prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: buyer.id,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
paymentMethod: 'online',
|
||||||
|
itemsSubtotalCents: 10000,
|
||||||
|
deliveryFeeCents: 50000,
|
||||||
|
totalCents: 60000,
|
||||||
|
currency: 'RUB',
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin order routes', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.payment.deleteMany()
|
||||||
|
await prisma.order.deleteMany({ where: { user: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } })
|
||||||
|
|
||||||
|
adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL } })
|
||||||
|
buyer = await prisma.user.create({ data: { email: USER_EMAIL } })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.order.deleteMany({ where: { userId: buyer.id } })
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.order.deleteMany({ where: { userId: buyer.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [adminUser.id, buyer.id] } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('summary counts only delivery orders waiting for price approval', async () => {
|
||||||
|
await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' })
|
||||||
|
await createOrder({ deliveryFeeLocked: true, deliveryType: 'delivery' })
|
||||||
|
await createOrder({ deliveryFeeLocked: false, deliveryType: 'pickup' })
|
||||||
|
await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery', status: 'PAID' })
|
||||||
|
|
||||||
|
const token = await signToken(adminUser)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/orders/summary',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json()).toEqual({ attentionCount: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects PAID transition while delivery fee is not locked', async () => {
|
||||||
|
const order = await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' })
|
||||||
|
const token = await signToken(adminUser)
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/orders/${order.id}/status`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { status: 'PAID' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(409)
|
||||||
|
expect(res.json().error).toContain('стоимость доставки')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запустить тест и убедиться, что он падает**
|
||||||
|
|
||||||
|
Run from `server`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/routes/api/__tests__/admin-orders.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected before implementation: first test returns `{ attentionCount: 2 }` or more; second test returns 200 instead of 409.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Исправить admin summary и PAID guard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/routes/api/admin-orders.js`
|
||||||
|
- Test: `server/src/routes/api/__tests__/admin-orders.test.js`
|
||||||
|
- Update: `docs/superpowers/audits/2026-05-28-orders-payments-audit.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Исправить summary filter**
|
||||||
|
|
||||||
|
In `server/src/routes/api/admin-orders.js`, replace the summary `where`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const attentionCount = await prisma.order.count({
|
||||||
|
where: {
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Добавить guard перед update статуса**
|
||||||
|
|
||||||
|
In `server/src/routes/api/admin-orders.js`, after `canTransitionAdminOrderStatus` check and before `prisma.order.update`, add:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (next === 'PAID' && existing.deliveryType === 'delivery' && existing.deliveryFeeLocked === false) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'Сначала подтвердите итоговую стоимость доставки',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Запустить focused server test**
|
||||||
|
|
||||||
|
Run from `server`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/routes/api/__tests__/admin-orders.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Обновить audit report statuses**
|
||||||
|
|
||||||
|
In `docs/superpowers/audits/2026-05-28-orders-payments-audit.md`, change:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Статус:** pending
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Статус:** fixed
|
||||||
|
```
|
||||||
|
|
||||||
|
for `P1-003` and `P1-004`, and add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Тест:** `server/src/routes/api/__tests__/admin-orders.test.js`
|
||||||
|
```
|
||||||
|
|
||||||
|
to both findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Зафиксировать SSE queryKey mismatch тестами
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/app/providers/__tests__/SseProvider.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Обновить ожидания для order events**
|
||||||
|
|
||||||
|
In `client/src/app/providers/__tests__/SseProvider.test.tsx`, change the `message:new`, `order:statusChanged`, and `order:updated` expectations so each order event expects:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', orderId] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', orderId] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
```
|
||||||
|
|
||||||
|
Use concrete `orderId` values already present in each test: `o1`, `o2`, `o3`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запустить focused client test и убедиться, что он падает**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/app/providers/__tests__/SseProvider.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected before implementation: FAIL because `['admin', 'orders', 'detail', orderId]`, list and summary are not invalidated for existing order events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Исправить SSE invalidation keys
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/app/providers/SseProvider.tsx`
|
||||||
|
- Test: `client/src/app/providers/__tests__/SseProvider.test.tsx`
|
||||||
|
- Update: `docs/superpowers/audits/2026-05-28-orders-payments-audit.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Добавить локальный helper внутри useEffect**
|
||||||
|
|
||||||
|
In `client/src/app/providers/SseProvider.tsx`, inside `useEffect` before `handleEvent`, add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function invalidateOrderQueries(orderId: unknown) {
|
||||||
|
if (!orderId) return
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Использовать helper в three order event cases**
|
||||||
|
|
||||||
|
Replace duplicated `if (orderId) { ... }` blocks in `message:new`, `order:statusChanged`, and `order:updated` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
invalidateOrderQueries(orderId)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `message:new` invalidations for unread count and conversations before this call.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Запустить focused client test**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/app/providers/__tests__/SseProvider.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Обновить audit report**
|
||||||
|
|
||||||
|
Mark `P1-001` as fixed and add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Тест:** `client/src/app/providers/__tests__/SseProvider.test.tsx`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Зафиксировать и исправить authorType в админском чате
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
- Modify: `client/src/features/order-detail/ui/OrderDetailContent.tsx`
|
||||||
|
- Update: `docs/superpowers/audits/2026-05-28-orders-payments-audit.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Замокать ChatMessageBubble в тесте**
|
||||||
|
|
||||||
|
In `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`, add this mock after existing UI mocks:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vi.mock('@/shared/ui/ChatMessageBubble', () => ({
|
||||||
|
ChatMessageBubble: ({ authorType, children }: { authorType: 'admin' | 'user'; children: React.ReactNode }) => (
|
||||||
|
<div data-testid={`chat-message-${authorType}`}>{children}</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Добавить failing test**
|
||||||
|
|
||||||
|
Add to `describe('OrderDetailContent quick status transitions', () => { ... })`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('передает фактический authorType в пузырь сообщения', () => {
|
||||||
|
renderComponent(
|
||||||
|
createDetail({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'message-admin',
|
||||||
|
authorType: 'admin',
|
||||||
|
text: 'Ответ администратора',
|
||||||
|
attachmentUrl: null,
|
||||||
|
createdAt: '2026-05-28T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message-user',
|
||||||
|
authorType: 'user',
|
||||||
|
text: 'Сообщение покупателя',
|
||||||
|
attachmentUrl: null,
|
||||||
|
createdAt: '2026-05-28T10:01:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('chat-message-admin')).toHaveTextContent('Ответ администратора')
|
||||||
|
expect(screen.getByTestId('chat-message-user')).toHaveTextContent('Сообщение покупателя')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Запустить focused test и убедиться, что он падает**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected before implementation: FAIL because admin message is rendered under `chat-message-user`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Исправить implementation**
|
||||||
|
|
||||||
|
In `client/src/features/order-detail/ui/OrderDetailContent.tsx`, replace:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'user' : 'admin'} avatar={avatarNode}>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Запустить focused test**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Обновить audit report**
|
||||||
|
|
||||||
|
Mark `P1-002` as fixed and add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Тест:** `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Исправить клиентский status helper без delivery-only fallback
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/shared/constants/order.ts`
|
||||||
|
- Create: `client/src/shared/constants/__tests__/order.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Создать тесты для delivery и pickup переходов**
|
||||||
|
|
||||||
|
Create `client/src/shared/constants/__tests__/order.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canTransitionOrderStatus, getAdminNextOrderStatuses } from '../order'
|
||||||
|
|
||||||
|
describe('client order status helpers', () => {
|
||||||
|
it('returns delivery-specific next statuses', () => {
|
||||||
|
expect(getAdminNextOrderStatuses('IN_PROGRESS', 'delivery')).toEqual(['SHIPPED', 'CANCELLED'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns pickup-specific next statuses', () => {
|
||||||
|
expect(getAdminNextOrderStatuses('IN_PROGRESS', 'pickup')).toEqual(['READY_FOR_PICKUP', 'CANCELLED'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks pickup transition without falling back to delivery rules', () => {
|
||||||
|
expect(canTransitionOrderStatus('IN_PROGRESS', 'READY_FOR_PICKUP', 'pickup')).toBe(true)
|
||||||
|
expect(canTransitionOrderStatus('IN_PROGRESS', 'SHIPPED', 'pickup')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запустить тест и убедиться, что он падает на сигнатуре**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/shared/constants/__tests__/order.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected before implementation: TypeScript/test failure because `canTransitionOrderStatus` accepts only `(from, to)` and hardcodes delivery.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Изменить helper signature**
|
||||||
|
|
||||||
|
In `client/src/shared/constants/order.ts`, replace `canTransitionOrderStatus` with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function canTransitionOrderStatus(
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
deliveryType: 'delivery' | 'pickup',
|
||||||
|
): boolean {
|
||||||
|
if (from === to) return true
|
||||||
|
return getAdminNextOrderStatuses(from, deliveryType).includes(to as OrderStatus)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Проверить callers**
|
||||||
|
|
||||||
|
Run from repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n "canTransitionOrderStatus\\(" "client/src"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only the new test imports/calls it, or all callers pass `deliveryType`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Запустить focused test**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test -- src/shared/constants/__tests__/order.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Полные проверки и финализация отчета
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Update: `docs/superpowers/audits/2026-05-28-orders-payments-audit.md`
|
||||||
|
- Verify: changed server/client files
|
||||||
|
|
||||||
|
- [ ] **Step 1: Запустить server checks**
|
||||||
|
|
||||||
|
Run from `server`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Запустить client checks**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npm test -- src/app/providers/__tests__/SseProvider.test.tsx src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx src/shared/constants/__tests__/order.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all commands pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Запустить build при изменении TypeScript сигнатур**
|
||||||
|
|
||||||
|
Run from `client`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: typecheck and Vite build pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Финализировать audit report summary**
|
||||||
|
|
||||||
|
Update `docs/superpowers/audits/2026-05-28-orders-payments-audit.md` summary to:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- P0: не найдено в первой итерации.
|
||||||
|
- P1: исправлены SSE/queryKey для админской деталки, authorType админского чата, summary внимания и guard оплаты до подтверждения доставки.
|
||||||
|
- P2: добавлены focused tests для найденных дефектов.
|
||||||
|
- P3: рефакторинги оставлены в backlog.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Зафиксировать проверки в audit report**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `server npm test` — pass
|
||||||
|
- `server npm run lint` — pass
|
||||||
|
- `server npm run format:check` — pass
|
||||||
|
- `client npm run lint` — pass
|
||||||
|
- `client npm run format:check` — pass
|
||||||
|
- `client focused vitest` — pass
|
||||||
|
- `client npm run build` — pass
|
||||||
|
```
|
||||||
|
|
||||||
|
If a command fails for an unrelated existing issue, record the exact failing command and reason instead of marking it pass.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Проверить рабочее дерево**
|
||||||
|
|
||||||
|
Run from repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only files from this plan and any intentional generated files are changed.
|
||||||
|
|
||||||
|
Do not commit unless the user explicitly asks for a commit.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Дизайн: аудит заказов, оплаты и админки заказов
|
||||||
|
|
||||||
|
**Дата:** 2026-05-28
|
||||||
|
**Статус:** Ready for implementation planning
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Проект — магазин изделий ручной работы с витриной, личным кабинетом и админкой. Самый рискованный бизнес-поток сейчас проходит через создание заказа, доставку, подтверждение цены, оплату, смену статусов, чат заказа и синхронизацию UI через React Query/SSE.
|
||||||
|
|
||||||
|
Предыдущий документ `docs/superpowers/specs/2026-05-28-admin-orders-improvements-design.md` описывает UX-доработку админки заказов. Этот документ шире: он задает дизайн аудита логики и исправлений вокруг заказов и оплаты.
|
||||||
|
|
||||||
|
## Цели
|
||||||
|
|
||||||
|
- Найти логические ошибки и рассинхроны в потоке заказа от checkout до финального статуса.
|
||||||
|
- Приоритизировать проблемы по риску для данных, оплаты, остатков и пользовательского опыта.
|
||||||
|
- Исправить только подтвержденные проблемы высокой ценности, не превращая аудит в большой несвязанный рефакторинг.
|
||||||
|
- Подготовить отдельный список безопасных рефакторингов, которые стоит делать после стабилизации тестами.
|
||||||
|
|
||||||
|
## Не в рамках этой итерации
|
||||||
|
|
||||||
|
- Редизайн витрины, карточек товара или всей админки.
|
||||||
|
- Полный аудит всех CRUD-разделов админки.
|
||||||
|
- Миграция с SQLite или изменение инфраструктуры деплоя.
|
||||||
|
- Новая ролевая модель вместо текущего `verifyAdmin`.
|
||||||
|
- Переработка публичных контрактов `/api/categories` и `/api/products`.
|
||||||
|
|
||||||
|
## Рекомендованный подход
|
||||||
|
|
||||||
|
Первая итерация должна быть сфокусирована на сквозном потоке `orders/payments/admin-orders`, а не на всем проекте сразу. Этот поток объединяет серверные инварианты, платежи, остатки, админские действия, React Query cache и realtime-обновления, поэтому дает максимальную отдачу от аудита.
|
||||||
|
|
||||||
|
Альтернативы:
|
||||||
|
|
||||||
|
- Timebox-аудит всего проекта даст широкую карту проблем, но исправления окажутся менее связными.
|
||||||
|
- Отдельный frontend-only или backend-only аудит проще выполнить локально, но он хуже ловит ошибки на стыке API, статусов, кеша и UI.
|
||||||
|
|
||||||
|
## Область анализа
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `server/src/routes/user-orders.js` — создание заказа, checkout, списание остатков, доставка.
|
||||||
|
- `server/src/routes/user-payments.js` — создание и проверка платежа.
|
||||||
|
- `server/src/routes/webhook-yookassa.js` — обработка платежных вебхуков.
|
||||||
|
- `server/src/routes/api/admin-orders.js` — список, деталка, summary, смена статуса, доставка, сообщения.
|
||||||
|
- `server/src/routes/user-messages.js` — пользовательские сообщения заказа.
|
||||||
|
- `server/src/routes/user-cart.js` — корзина как источник checkout.
|
||||||
|
- `server/src/plugins/auth.js` — админская проверка только в части влияния на order API.
|
||||||
|
- `shared/constants/order-status.js` и `server/src/lib/order-status.js` — допустимые переходы статусов.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `client/src/pages/admin-orders/` — список заказов, диалог деталки, summary.
|
||||||
|
- `client/src/features/order-detail/` — деталка заказа, статусы, доставка, чат, оплата.
|
||||||
|
- `client/src/pages/me/ui/sections/` — пользовательская деталка заказа, сообщения, действия по заказу.
|
||||||
|
- `client/src/entities/order/api/` — контракты клиентского API.
|
||||||
|
- `client/src/app/providers/SseProvider.tsx` — realtime-инвалидации.
|
||||||
|
- Order-related query keys и мутации React Query.
|
||||||
|
|
||||||
|
## Проверяемые инварианты
|
||||||
|
|
||||||
|
### Заказ и остатки
|
||||||
|
|
||||||
|
- Checkout не создает заказ при нехватке товара.
|
||||||
|
- Остатки списываются атомарно и не уходят в отрицательные значения.
|
||||||
|
- Если отмена заказа не возвращает остатки, это фиксируется в audit report как явное продуктовое решение или дефект.
|
||||||
|
- Корзина после успешного checkout очищается согласованно с созданным заказом.
|
||||||
|
|
||||||
|
### Доставка и цена
|
||||||
|
|
||||||
|
- Для доставки состояние `deliveryFeeLocked` соответствует возможности оплаты.
|
||||||
|
- Заказы, требующие подтверждения цены доставки, видны администратору в списке заказов и учитываются в summary, если summary заявлен как список задач к вниманию.
|
||||||
|
- Админ не переводит заказ в состояние, которое противоречит неподтвержденной доставке.
|
||||||
|
|
||||||
|
### Оплата
|
||||||
|
|
||||||
|
- Платеж нельзя создать для заказа, который еще не готов к оплате.
|
||||||
|
- Webhook и polling не создают дублирующих побочных эффектов.
|
||||||
|
- Переход в `PAID` идемпотентен и не ломает уведомления.
|
||||||
|
- Ручной перевод админом в `PAID` либо запрещен, либо явно обоснован и протестирован.
|
||||||
|
|
||||||
|
### Статусы
|
||||||
|
|
||||||
|
- Все переходы статусов проходят через единый источник правил.
|
||||||
|
- Frontend не дублирует серверную бизнес-логику переходов.
|
||||||
|
- Для delivery и pickup сценариев допустимые переходы не расходятся.
|
||||||
|
- Финальные статусы нельзя случайно изменить через admin API.
|
||||||
|
|
||||||
|
### UI и синхронизация
|
||||||
|
|
||||||
|
- Query keys деталки, списка, summary и SSE-инвалидаций согласованы.
|
||||||
|
- После мутаций админка и личный кабинет не показывают устаревший статус.
|
||||||
|
- Сообщения чата корректно отображают автора в пользовательской и админской деталке.
|
||||||
|
- Ошибки API отображаются понятным текстом и не скрываются за silent fail.
|
||||||
|
|
||||||
|
## Приоритизация находок
|
||||||
|
|
||||||
|
- **P0:** риск потери денег, некорректной оплаты, отрицательных остатков, доступа к чужим данным.
|
||||||
|
- **P1:** неверный статус заказа, сломанная админская операция, рассинхрон UI после успешного действия.
|
||||||
|
- **P2:** плохая диагностика ошибок, слабое покрытие тестами вокруг важного сценария.
|
||||||
|
- **P3:** локальный техдолг и рефакторинг без текущего пользовательского эффекта.
|
||||||
|
|
||||||
|
В первой реализации исправляются P0/P1 и небольшие P2, если они нужны для тестового закрепления. P3 фиксируются в отчете и не смешиваются с исправлениями.
|
||||||
|
|
||||||
|
## Артефакты
|
||||||
|
|
||||||
|
1. Audit report в формате списка находок с приоритетом, сценарием воспроизведения или кодовой причиной.
|
||||||
|
2. Набор точечных исправлений для подтвержденных P0/P1.
|
||||||
|
3. Тесты, которые фиксируют исправленное поведение.
|
||||||
|
4. Отдельный список рефакторингов и доработок на следующую итерацию.
|
||||||
|
|
||||||
|
## Тестирование и проверки
|
||||||
|
|
||||||
|
Минимальный набор проверок после исправлений:
|
||||||
|
|
||||||
|
- `server` → `npm test`
|
||||||
|
- `server` → `npm run lint`
|
||||||
|
- `server` → `npm run format:check`
|
||||||
|
- `client` → `npm run lint`
|
||||||
|
- `client` → `npm run format:check`
|
||||||
|
|
||||||
|
Дополнительно по затронутым изменениям:
|
||||||
|
|
||||||
|
- `client` → `npm test`
|
||||||
|
- `client` → `npm run build`
|
||||||
|
- ручной smoke-сценарий: checkout delivery, подтверждение доставки админом, оплата, смена статусов, обновление открытой деталки.
|
||||||
|
|
||||||
|
## Риски
|
||||||
|
|
||||||
|
- Аудит может найти больше проблем, чем стоит исправлять в одной итерации. Мера: исправлять только P0/P1, остальное переносить в отчет.
|
||||||
|
- Статусная модель может оказаться связана с UX-решениями, которые не очевидны из кода. Мера: спорные изменения фиксировать как предложение, а не вносить без подтвержденного инварианта.
|
||||||
|
- Сквозные тесты могут потребовать подготовки данных. Мера: начинать с route/unit/component тестов рядом с найденным дефектом.
|
||||||
|
|
||||||
|
## Критерии готовности
|
||||||
|
|
||||||
|
- Каждая исправленная проблема имеет тест или четкую проверку.
|
||||||
|
- Публичные роуты `/api/categories` и `/api/products` не меняются.
|
||||||
|
- FSD-границы фронтенда не нарушены.
|
||||||
|
- API ошибки для админских операций остаются понятными: 400/401/404/409.
|
||||||
|
- Все релевантные проверки из раздела тестирования пройдены или явно перечислены как не запущенные с причиной.
|
||||||
Binary file not shown.
@@ -0,0 +1,118 @@
|
|||||||
|
import jwt from '@fastify/jwt'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { prisma } from '../../../lib/prisma.js'
|
||||||
|
import { registerAdminOrderRoutes } from '../admin-orders.js'
|
||||||
|
|
||||||
|
const JWT_SECRET = 'test-secret'
|
||||||
|
const ADMIN_EMAIL = `admin-orders-${Date.now()}@example.com`
|
||||||
|
const USER_EMAIL = `admin-orders-user-${Date.now()}@example.com`
|
||||||
|
|
||||||
|
let app
|
||||||
|
let adminUser
|
||||||
|
let buyer
|
||||||
|
|
||||||
|
async function signToken(user) {
|
||||||
|
return app.jwt.sign({ sub: user.id, email: user.email })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const fastify = Fastify({ logger: false })
|
||||||
|
await fastify.register(jwt, { secret: JWT_SECRET })
|
||||||
|
fastify.decorate('eventBus', { emit: vi.fn() })
|
||||||
|
fastify.decorate('verifyAdmin', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
if (request.user.email !== ADMIN_EMAIL) {
|
||||||
|
return reply.code(401).send({ error: 'Admin only' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await registerAdminOrderRoutes(fastify)
|
||||||
|
await fastify.ready()
|
||||||
|
return fastify
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOrder(data = {}) {
|
||||||
|
return prisma.order.create({
|
||||||
|
data: {
|
||||||
|
userId: buyer.id,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
paymentMethod: 'online',
|
||||||
|
itemsSubtotalCents: 10000,
|
||||||
|
deliveryFeeCents: 50000,
|
||||||
|
totalCents: 60000,
|
||||||
|
currency: 'RUB',
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin order routes', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await prisma.payment.deleteMany()
|
||||||
|
await prisma.order.deleteMany({ where: { user: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } } })
|
||||||
|
await prisma.user.deleteMany({ where: { email: { in: [ADMIN_EMAIL, USER_EMAIL] } } })
|
||||||
|
|
||||||
|
adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL } })
|
||||||
|
buyer = await prisma.user.create({ data: { email: USER_EMAIL } })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.order.deleteMany({ where: { userId: buyer.id } })
|
||||||
|
app = await buildApp()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.order.deleteMany({ where: { userId: buyer.id } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [adminUser.id, buyer.id] } } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('summary counts only delivery orders waiting for price approval', async () => {
|
||||||
|
const token = await signToken(adminUser)
|
||||||
|
const beforeRes = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/orders/summary',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const baseline = beforeRes.json().attentionCount
|
||||||
|
|
||||||
|
await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' })
|
||||||
|
await createOrder({ deliveryFeeLocked: true, deliveryType: 'delivery' })
|
||||||
|
await createOrder({ deliveryFeeLocked: false, deliveryType: 'pickup' })
|
||||||
|
await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery', status: 'PAID' })
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/orders/summary',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
expect(res.json()).toEqual({ attentionCount: baseline + 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects PAID transition while delivery fee is not locked', async () => {
|
||||||
|
const order = await createOrder({ deliveryFeeLocked: false, deliveryType: 'delivery' })
|
||||||
|
const token = await signToken(adminUser)
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/admin/orders/${order.id}/status`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { status: 'PAID' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(409)
|
||||||
|
expect(res.json().error).toContain('стоимость доставки')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,8 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
const attentionCount = await prisma.order.count({
|
const attentionCount = await prisma.order.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { attentionCount }
|
return { attentionCount }
|
||||||
@@ -97,6 +99,11 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
error: `Нельзя сменить статус ${existing.status} → ${next}`,
|
error: `Нельзя сменить статус ${existing.status} → ${next}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (next === 'PAID' && existing.deliveryType === 'delivery' && existing.deliveryFeeLocked === false) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'Сначала подтвердите итоговую стоимость доставки',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.order.update({
|
const updated = await prisma.order.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
Reference in New Issue
Block a user