Files
shop-server/docs/superpowers/plans/2026-05-28-project-audit-orders-implementation.md
T
2026-05-28 21:46:17 +05:00

22 KiB

Аудит заказов и оплаты 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:

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:

# Аудит заказов, оплаты и админки заказов

**Дата:** 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:

$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:

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:

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:

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:

if (next === 'PAID' && existing.deliveryType === 'delivery' && existing.deliveryFeeLocked === false) {
  return reply.code(409).send({
    error: 'Сначала подтвердите итоговую стоимость доставки',
  })
}
  • Step 3: Запустить focused server test

Run from server:

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:

**Статус:** pending

to:

**Статус:** fixed

for P1-003 and P1-004, and add:

**Тест:** `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:

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:

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:

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:

invalidateOrderQueries(orderId)

Keep message:new invalidations for unread count and conversations before this call.

  • Step 3: Запустить focused client test

Run from client:

npm test -- src/app/providers/__tests__/SseProvider.test.tsx

Expected: PASS.

  • Step 4: Обновить audit report

Mark P1-001 as fixed and add:

**Тест:** `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:

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', () => { ... }):

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:

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:

<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'user' : 'admin'} avatar={avatarNode}>

with:

<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
  • Step 5: Запустить focused test

Run from client:

npm test -- src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx

Expected: PASS.

  • Step 6: Обновить audit report

Mark P1-002 as fixed and add:

**Тест:** `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:

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:

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:

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:

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:

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:

npm test
npm run lint
npm run format:check

Expected: all commands pass.

  • Step 2: Запустить client checks

Run from client:

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:

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:

## Summary

- P0: не найдено в первой итерации.
- P1: исправлены SSE/queryKey для админской деталки, authorType админского чата, summary внимания и guard оплаты до подтверждения доставки.
- P2: добавлены focused tests для найденных дефектов.
- P3: рефакторинги оставлены в backlog.
  • Step 5: Зафиксировать проверки в audit report

Add:

## 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:

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.