Редактор схем с VPS-нодами, связями и персистом в SQLite по space. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { balanceLedgerRoutes } from './routes/balance-ledger.js'
|
||||
import { settingsRoutes } from './routes/settings.js'
|
||||
import { syncRoutes } from './routes/sync.js'
|
||||
import { projectsRoutes } from './routes/projects.js'
|
||||
import { topologyRoutes } from './routes/topology.js'
|
||||
import { backupRoutes } from './routes/backup.js'
|
||||
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
||||
import { migrateRoutes } from './routes/migrate.js'
|
||||
@@ -62,6 +63,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(settingsRoutes)
|
||||
await app.register(syncRoutes)
|
||||
await app.register(projectsRoutes)
|
||||
await app.register(topologyRoutes)
|
||||
await app.register(backupRoutes)
|
||||
await app.register(ratesProxyRoutes)
|
||||
await app.register(migrateRoutes)
|
||||
|
||||
@@ -50,13 +50,16 @@ const RULES: Rule[] = [
|
||||
p === '/api/vps' ||
|
||||
p.startsWith('/api/vps/') ||
|
||||
p.startsWith('/api/projects') ||
|
||||
p.startsWith('/api/topology') ||
|
||||
p.startsWith('/api/data'),
|
||||
permission: 'vps:vps:read',
|
||||
},
|
||||
{
|
||||
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
p.startsWith('/api/vps') || p.startsWith('/api/projects'),
|
||||
p.startsWith('/api/vps') ||
|
||||
p.startsWith('/api/projects') ||
|
||||
p.startsWith('/api/topology'),
|
||||
permission: 'vps:vps:write',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { topologyRepository } from '@cfdm/db/repositories/topology'
|
||||
import { buildApp } from '../index.js'
|
||||
|
||||
describe('topology routes', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('lists empty diagrams', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/topology' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('creates and gets diagram', async () => {
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/topology',
|
||||
payload: { name: 'Мастер' },
|
||||
})
|
||||
expect(create.statusCode).toBe(201)
|
||||
const created = create.json() as { id: string; name: string; document: unknown }
|
||||
expect(created.name).toBe('Мастер')
|
||||
expect(created.document).toMatchObject({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
|
||||
const get = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/topology/${created.id}`,
|
||||
})
|
||||
expect(get.statusCode).toBe(200)
|
||||
expect(get.json()).toMatchObject({ id: created.id, name: 'Мастер' })
|
||||
})
|
||||
|
||||
it('updates document and returns 409 on stale expectedUpdatedAt', async () => {
|
||||
const diagram = topologyRepository.create({ name: 'Схема 1' })
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/topology/${diagram.id}`,
|
||||
payload: {
|
||||
document: {
|
||||
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: {} }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1.2 },
|
||||
},
|
||||
expectedUpdatedAt: diagram.updatedAt,
|
||||
},
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
const updated = put.json() as { updatedAt: string; document: { nodes: unknown[] } }
|
||||
expect(updated.document.nodes).toHaveLength(1)
|
||||
|
||||
const stale = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/topology/${diagram.id}`,
|
||||
payload: {
|
||||
name: 'Stale',
|
||||
expectedUpdatedAt: diagram.updatedAt,
|
||||
},
|
||||
})
|
||||
expect(stale.statusCode).toBe(409)
|
||||
})
|
||||
|
||||
it('deletes diagram', async () => {
|
||||
const diagram = topologyRepository.create({ name: 'Delete me' })
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/topology/${diagram.id}`,
|
||||
})
|
||||
expect(res.statusCode).toBe(204)
|
||||
expect(topologyRepository.get(diagram.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import {
|
||||
topologyCreateSchema,
|
||||
topologyUpdateSchema,
|
||||
} from '@cfdm/shared/contracts/topology'
|
||||
import { topologyRepository } from '@cfdm/db/repositories/topology'
|
||||
import { canWriteInSpace, requireSpaceRole } from '../plugins/space.js'
|
||||
|
||||
export const topologyRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/topology', async () => topologyRepository.list())
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
|
||||
const diagram = topologyRepository.get(req.params.id)
|
||||
if (!diagram) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
|
||||
}
|
||||
return diagram
|
||||
})
|
||||
|
||||
app.post('/api/topology', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
if (!canWriteInSpace(req)) {
|
||||
return reply.code(403).send({
|
||||
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
|
||||
})
|
||||
}
|
||||
const parsed = topologyCreateSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION',
|
||||
message: parsed.error.issues[0]?.message ?? 'Некорректные данные',
|
||||
},
|
||||
})
|
||||
}
|
||||
const created = topologyRepository.create(parsed.data)
|
||||
return reply.code(201).send(created)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
if (!canWriteInSpace(req)) {
|
||||
return reply.code(403).send({
|
||||
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
|
||||
})
|
||||
}
|
||||
const parsed = topologyUpdateSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION',
|
||||
message: parsed.error.issues[0]?.message ?? 'Некорректные данные',
|
||||
},
|
||||
})
|
||||
}
|
||||
const result = topologyRepository.update(req.params.id, parsed.data)
|
||||
if (!result.ok && result.reason === 'not_found') {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
|
||||
}
|
||||
if (!result.ok && result.reason === 'stale') {
|
||||
return reply.code(409).send({
|
||||
error: {
|
||||
code: 'CONFLICT',
|
||||
message: 'Схема изменена на другом устройстве',
|
||||
},
|
||||
diagram: result.current,
|
||||
})
|
||||
}
|
||||
if (!result.ok) {
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Ошибка обновления' } })
|
||||
}
|
||||
return result.diagram
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/topology/:id', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
if (!canWriteInSpace(req)) {
|
||||
return reply.code(403).send({
|
||||
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
|
||||
})
|
||||
}
|
||||
const ok = topologyRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Схема не найдена' } })
|
||||
}
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user