Files
shop-server/docs/superpowers/plans/2026-05-27-server-duplication.md
T
2026-05-27 20:56:08 +05:00

9.2 KiB

Server Duplication — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.

Goal: Устранить дублирование в серверных роутах: asyncHandler декоратор, validateGalleryImages, findUserOrder.

Architecture: Вынос повторяющихся паттернов в shared-хелперы (server/src/lib/), замена ручного try/catch на декоратор.

Tech Stack: JavaScript, Fastify, Prisma, Vitest

Depends on: none


Task 1: asyncHandler декоратор + тесты

Files:

  • Create: server/src/lib/async-handler.js

  • Test: server/src/lib/__tests__/async-handler.test.js

  • Step 1: Write failing tests

// server/src/lib/__tests__/async-handler.test.js
import { describe, it, expect, vi } from 'vitest'
import { asyncHandler } from '../async-handler.js'

describe('asyncHandler', () => {
  it('calls the handler and returns result on success', async () => {
    const handler = vi.fn().mockResolvedValue({ hello: 'world' })
    const request = {}
    const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
    await asyncHandler(handler)(request, reply)
    expect(handler).toHaveBeenCalledWith(request, reply)
    expect(reply.send).toHaveBeenCalledWith({ hello: 'world' })
  })

  it('catches errors and sends 500 with message', async () => {
    const handler = vi.fn().mockRejectedValue(new Error('boom'))
    const request = { log: { error: vi.fn() } }
    const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
    await asyncHandler(handler)(request, reply)
    expect(reply.code).toHaveBeenCalledWith(500)
    expect(reply.send).toHaveBeenCalledWith({ error: 'Internal server error' })
  })

  it('uses statusCode from error if present', async () => {
    const err = new Error('Not found')
    err.statusCode = 404
    const handler = vi.fn().mockRejectedValue(err)
    const request = { log: { error: vi.fn() } }
    const reply = { code: vi.fn().mockReturnThis(), send: vi.fn() }
    await asyncHandler(handler)(request, reply)
    expect(reply.code).toHaveBeenCalledWith(404)
    expect(reply.send).toHaveBeenCalledWith({ error: 'Not found' })
  })
})
  • Step 2: Run to verify failure

Run: cd server && npx vitest run lib/__tests__/async-handler.test.js Expected: FAIL

  • Step 3: Implement asyncHandler
// server/src/lib/async-handler.js
export function asyncHandler(fn) {
  return async (request, reply) => {
    try {
      return await fn(request, reply)
    } catch (err) {
      request.log.error(err)
      const statusCode = err.statusCode || 500
      const message = err.statusCode ? err.message : 'Internal server error'
      return reply.code(statusCode).send({ error: message })
    }
  }
}
  • Step 4: Run tests

Run: cd server && npx vitest run lib/__tests__/async-handler.test.js Expected: PASS

  • Step 5: Commit
git add server/src/lib/async-handler.js server/src/lib/__tests__/async-handler.test.js
git commit -m "feat: add asyncHandler decorator for route error handling"

Task 2: Применить asyncHandler в роутах

Files:

  • Modify: server/src/routes/user-orders.js

  • Modify: server/src/routes/user-messages.js

  • Modify: server/src/routes/user-payments.js

  • Modify: server/src/routes/admin-gallery.js

  • Modify: all other route files with manual try/catch

  • Step 1: Найти все ручные try/catch в роутах

Run: rg 'try\s*\{' server/src/routes/ --include '*.js'

  • Step 2: Заменить каждый try/catch на asyncHandler

Before:

fastify.get('/orders', async (request, reply) => {
  try {
    const orders = await prisma.order.findMany(...)
    return reply.send(orders)
  } catch (err) {
    request.log.error(err)
    return reply.code(500).send({ error: 'Internal server error' })
  }
})

After:

import { asyncHandler } from '../../lib/async-handler.js'

fastify.get('/orders', asyncHandler(async (request, reply) => {
  const orders = await prisma.order.findMany(...)
  return reply.send(orders)
}))
  • Step 3: Run lint + tests
cd server && npm run lint && npm test
  • Step 4: Commit
git add server/src/routes/
git commit -m "refactor: apply asyncHandler to all route handlers"

Task 3: validateGalleryImages хелпер

Files:

  • Create: server/src/lib/validate-gallery-images.js

  • Test: server/src/lib/__tests__/validate-gallery-images.test.js

  • Modify: server/src/routes/api/admin-products.js

  • Step 1: Write tests

// server/src/lib/__tests__/validate-gallery-images.test.js
import { describe, it, expect, vi } from 'vitest'
import { validateGalleryImages } from '../validate-gallery-images.js'

describe('validateGalleryImages', () => {
  it('returns null when galleryImages is empty', async () => {
    const prisma = { galleryImage: { findMany: vi.fn() } }
    const result = await validateGalleryImages(prisma, [])
    expect(result).toBeNull()
  })

  it('throws when image not found', async () => {
    const prisma = { galleryImage: { findMany: vi.fn().mockResolvedValue([]) } }
    await expect(validateGalleryImages(prisma, [1])).rejects.toThrow('not found')
  })
})
  • Step 2: Implement
// server/src/lib/validate-gallery-images.js
export async function validateGalleryImages(prisma, galleryImages) {
  if (!galleryImages || galleryImages.length === 0) return null

  const existing = await prisma.galleryImage.findMany({
    where: { id: { in: galleryImages } },
    select: { id: true, resized: true },
  })

  const foundIds = new Set(existing.map((img) => img.id))
  const missing = galleryImages.filter((id) => !foundIds.has(id))
  if (missing.length > 0) {
    throw Object.assign(new Error(`Gallery images not found: ${missing.join(', ')}`), { statusCode: 404 })
  }

  const notResized = existing.filter((img) => !img.resized)
  if (notResized.length > 0) {
    throw Object.assign(new Error('Some gallery images have not been processed yet. Please try again later.'), { statusCode: 400 })
  }

  return existing
}
  • Step 3: Заменить дублирующийся код в admin-products.js

Before (in POST /admin/products):

// validate gallery images
if (galleryImages && galleryImages.length > 0) {
  const existingImages = await prisma.galleryImage.findMany({ ... })
  // ... duplicate validation logic
}

After:

import { validateGalleryImages } from '../../lib/validate-gallery-images.js'

// single call
await validateGalleryImages(prisma, galleryImages)

Same replacement for PATCH /admin/products/:id.

  • Step 4: Run tests
cd server && npm run lint && npm test
  • Step 5: Commit
git add server/src/lib/validate-gallery-images.js server/src/lib/__tests__/validate-gallery-images.test.js server/src/routes/api/admin-products.js
git commit -m "refactor: extract validateGalleryImages helper"

Task 4: findUserOrder хелпер

Files:

  • Create: server/src/lib/find-user-order.js

  • Test: server/src/lib/__tests__/find-user-order.test.js

  • Modify: server/src/routes/user-orders.js

  • Modify: server/src/routes/user-messages.js

  • Modify: server/src/routes/user-payments.js

  • Step 1: Write tests

// server/src/lib/__tests__/find-user-order.test.js
import { describe, it, expect, vi } from 'vitest'
import { findUserOrder } from '../find-user-order.js'

describe('findUserOrder', () => {
  it('returns order when found', async () => {
    const mockOrder = { id: '1', userId: 'user1' }
    const prisma = { order: { findFirst: vi.fn().mockResolvedValue(mockOrder) } }
    const result = await findUserOrder(prisma, '1', 'user1')
    expect(result).toEqual(mockOrder)
    expect(prisma.order.findFirst).toHaveBeenCalledWith(expect.objectContaining({
      where: { id: '1', userId: 'user1' },
    }))
  })

  it('throws 404 when not found', async () => {
    const prisma = { order: { findFirst: vi.fn().mockResolvedValue(null) } }
    await expect(findUserOrder(prisma, '999', 'user1')).rejects.toMatchObject({ statusCode: 404 })
  })
})
  • Step 2: Implement
// server/src/lib/find-user-order.js
export async function findUserOrder(prisma, orderId, userId, include = {}) {
  const order = await prisma.order.findFirst({
    where: { id: orderId, userId },
    include,
  })

  if (!order) {
    throw Object.assign(new Error('Order not found'), { statusCode: 404 })
  }

  return order
}
  • Step 3: Заменить дублирующийся код в роутах

Each instance of:

const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Order not found' })

Becomes:

import { findUserOrder } from '../../lib/find-user-order.js'
const order = await findUserOrder(prisma, id, userId)
  • Step 4: Run tests
cd server && npm run lint && npm test
  • Step 5: Commit
git add server/src/lib/find-user-order.js server/src/lib/__tests__/find-user-order.test.js server/src/routes/user-{orders,messages,payments}.js
git commit -m "refactor: extract findUserOrder helper"