diff --git a/web/frontend/src/__tests__/deploy-standardized.test.ts b/web/frontend/src/__tests__/deploy-standardized.test.ts
index 9593a5e..7a90e83 100644
--- a/web/frontend/src/__tests__/deploy-standardized.test.ts
+++ b/web/frontend/src/__tests__/deploy-standardized.test.ts
@@ -235,6 +235,61 @@ describe('SettingsView — Deploy Config', () => {
})
})
+// ─────────────────────────────────────────────────────────────
+// 1b. SettingsView — Project Links
+// ─────────────────────────────────────────────────────────────
+describe('SettingsView — Project Links', () => {
+ it('link.type рендерится корректно в списке связей — не undefined (конвенция #527)', async () => {
+ // Bug #527: шаблон использовал {{ link.link_type }} вместо {{ link.type }} → runtime undefined
+ vi.mocked(api.projectLinks).mockResolvedValue([
+ { id: 1, from_project: 'KIN', to_project: 'BRS', type: 'triggers', description: null, created_at: '2026-01-01' },
+ ] as any)
+ vi.mocked(api.projects).mockResolvedValue([BASE_PROJECT as any])
+ const wrapper = mount(SettingsView)
+ await flushPromises()
+
+ // Тип связи должен отображаться как строка, а не undefined
+ expect(wrapper.text()).toContain('triggers')
+ expect(wrapper.text()).not.toContain('undefined')
+ })
+
+ it('addLink вызывает createProjectLink с полем type (не link_type) из формы', async () => {
+ // Bug #527: addLink() использовал link_type вместо type при вызове api
+ vi.mocked(api.projects).mockResolvedValue([
+ { ...BASE_PROJECT, id: 'BRS', name: 'Barsik' } as any,
+ BASE_PROJECT as any,
+ ])
+ vi.mocked(api.projectLinks).mockResolvedValue([])
+ const wrapper = mount(SettingsView)
+ await flushPromises()
+
+ // Открываем форму добавления связи для первого проекта
+ const addBtns = wrapper.findAll('button').filter(b => b.text().includes('+ Add Link'))
+ if (addBtns.length > 0) {
+ await addBtns[0].trigger('click')
+ await flushPromises()
+
+ // Выбираем to_project (в SettingsView это select без api.projects — используем allProjectList)
+ const selects = wrapper.findAll('select')
+ const toProjectSelect = selects.find(s => s.findAll('option').some(o => o.element.value !== '' && o.element.value !== 'depends_on' && o.element.value !== 'triggers' && o.element.value !== 'related_to'))
+ if (toProjectSelect) {
+ const opts = toProjectSelect.findAll('option').filter(o => o.element.value !== '')
+ if (opts.length > 0) await toProjectSelect.setValue(opts[0].element.value)
+ }
+
+ const form = wrapper.find('form')
+ await form.trigger('submit')
+ await flushPromises()
+
+ if (vi.mocked(api.createProjectLink).mock.calls.length > 0) {
+ const callArg = vi.mocked(api.createProjectLink).mock.calls[0][0]
+ expect(callArg).toHaveProperty('type')
+ expect(callArg).not.toHaveProperty('link_type')
+ }
+ }
+ })
+})
+
// ─────────────────────────────────────────────────────────────
// 2. ProjectView — Deploy кнопка
// ─────────────────────────────────────────────────────────────
@@ -603,6 +658,32 @@ describe('ProjectView — Links таб', () => {
}))
})
+ it('createProjectLink передаёт поле type (не link_type) — конвенция #527', async () => {
+ // Bug #527: addLink() передавал link_type вместо type → поле type отсутствовало в запросе
+ vi.mocked(api.projects).mockResolvedValue([
+ { ...BASE_PROJECT, id: 'BRS', name: 'Barsik' } as any,
+ ])
+ const wrapper = await mountProjectView()
+ await switchToLinksTab(wrapper)
+
+ const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
+ await plusBtn!.trigger('click')
+ await flushPromises()
+
+ const selects = wrapper.findAll('select')
+ const toProjectSelect = selects.find(s => s.findAll('option').some(o => o.text().includes('BRS')))
+ if (toProjectSelect) await toProjectSelect.setValue('BRS')
+
+ const form = wrapper.find('form')
+ await form.trigger('submit')
+ await flushPromises()
+
+ const callArg = vi.mocked(api.createProjectLink).mock.calls[0]?.[0]
+ expect(callArg).toBeDefined()
+ expect(callArg).toHaveProperty('type')
+ expect(callArg).not.toHaveProperty('link_type')
+ })
+
it('Delete вызывает api.deleteProjectLink с id связи', async () => {
const links = [
{ id: 7, from_project: 'KIN', to_project: 'BRS', type: 'depends_on', description: null, created_at: '2026-01-01' },
diff --git a/web/frontend/src/__tests__/filter-persistence.test.ts b/web/frontend/src/__tests__/filter-persistence.test.ts
index 7a784ac..b313950 100644
--- a/web/frontend/src/__tests__/filter-persistence.test.ts
+++ b/web/frontend/src/__tests__/filter-persistence.test.ts
@@ -905,6 +905,28 @@ describe('KIN-049: TaskDetail — кнопка Deploy', () => {
expect(hasDeployBtn, 'Deploy не должна быть видна при статусе review').toBe(false)
})
+ it('Кнопка Deploy видна при status=done с обоими полями одновременно (сосуществование #520)', async () => {
+ // Конвенция #520: legacy deploy_command и новый deploy_runtime могут быть заданы одновременно
+ vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', 'echo deploy.sh', 'node') as any)
+ const router = makeRouter()
+ await router.push('/task/KIN-049')
+
+ const wrapper = mount(TaskDetail, {
+ props: { id: 'KIN-049' },
+ global: { plugins: [router] },
+ })
+ await flushPromises()
+
+ const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
+ expect(deployBtn?.exists(), 'Deploy должна быть видна при одновременном наличии deploy_command и deploy_runtime').toBe(true)
+ })
+
+ it('makeDeployTask factory использует null по умолчанию для deployRuntime (конвенция #521)', () => {
+ // Конвенция #521: project_deploy_runtime: null — явное значение по умолчанию
+ const task = makeDeployTask('done', 'echo deploy.sh')
+ expect(task.project_deploy_runtime).toBe(null)
+ })
+
it('Клик по Deploy вызывает api.deployProject с project_id задачи', async () => {
vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', 'echo ok') as any)
vi.mocked(api.deployProject).mockResolvedValue({
diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts
index 1002848..8c2e8a0 100644
--- a/web/frontend/src/api.ts
+++ b/web/frontend/src/api.ts
@@ -190,6 +190,8 @@ export interface ProjectLink {
id: number
from_project: string
to_project: string
+ // WARNING (decision #527): поле называется `type` — так отдаёт бэкенд.
+ // НЕ переименовывать в link_type — это вызовет runtime undefined во всех компонентах.
type: string
description: string | null
created_at: string
diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue
index 9cd847e..88b3984 100644
--- a/web/frontend/src/views/SettingsView.vue
+++ b/web/frontend/src/views/SettingsView.vue
@@ -65,7 +65,7 @@ async function saveDeployConfig(projectId: string) {
deploy_path: deployPaths.value[projectId],
deploy_runtime: deployRuntimes.value[projectId],
deploy_restart_cmd: deployRestartCmds.value[projectId],
- deploy_command: deployCommands.value[projectId] || undefined,
+ deploy_command: deployCommands.value[projectId],
})
saveDeployConfigStatus.value[projectId] = 'Saved'
} catch (e: unknown) {
@@ -150,7 +150,7 @@ async function addLink(projectId: string) {
await api.createProjectLink({
from_project: projectId,
to_project: form.to_project,
- link_type: form.link_type,
+ type: form.link_type,
description: form.description || undefined,
})
showAddLinkForm.value[projectId] = false
@@ -306,7 +306,7 @@ async function deleteLink(projectId: string, linkId: number) {
{{ link.from_project }}
→
{{ link.to_project }}
- {{ link.link_type }}
+ {{ link.type }}
{{ link.description }}