fix(veesp): обновление IP, характеристик и валюты при повторном синке
Docker / build (push) Failing after 21s
Docker / build (push) Failing after 21s
Перезапись vcpu/ram/disk/ip на update, fetch VM ips, приоритет валюты из balance API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveProviderCurrency } from '@cfdm/shared/utils/currency'
|
||||
import { effectiveAccountBalanceCurrency } from '@cfdm/shared/utils/account-balance'
|
||||
import { effectiveAccountBalanceCurrency, syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
|
||||
|
||||
describe('resolveProviderCurrency', () => {
|
||||
it('prefers account currency over provider baseCurrency', () => {
|
||||
@@ -12,6 +12,26 @@ describe('resolveProviderCurrency', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncFallbackCurrency', () => {
|
||||
it('uses fresh balance currency when account currency is empty', () => {
|
||||
expect(
|
||||
syncFallbackCurrency(
|
||||
{ currency: '', providerBaseCurrency: 'EUR', balanceCurrency: 'EUR' },
|
||||
{ balanceCurrency: 'RUB' },
|
||||
),
|
||||
).toBe('RUB')
|
||||
})
|
||||
|
||||
it('prefers explicit account currency over balance', () => {
|
||||
expect(
|
||||
syncFallbackCurrency(
|
||||
{ currency: 'RUB', providerBaseCurrency: 'EUR', balanceCurrency: 'EUR' },
|
||||
{ balanceCurrency: 'EUR' },
|
||||
),
|
||||
).toBe('RUB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveAccountBalanceCurrency', () => {
|
||||
it('uses account currency for balance display', () => {
|
||||
expect(
|
||||
|
||||
@@ -67,14 +67,21 @@ function isIpv4(value: string): boolean {
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(value)
|
||||
}
|
||||
|
||||
function isIpv6(value: string): boolean {
|
||||
return value.includes(':')
|
||||
}
|
||||
|
||||
function normalizeIpField(raw: string | string[] | undefined | null): string {
|
||||
if (raw == null) return ''
|
||||
if (Array.isArray(raw)) {
|
||||
let fallback = ''
|
||||
for (const item of raw) {
|
||||
const ip = String(item).trim()
|
||||
if (!ip) continue
|
||||
if (isIpv4(ip)) return ip
|
||||
if (!fallback && isIpv6(ip)) fallback = ip
|
||||
}
|
||||
return String(raw[0] ?? '').trim()
|
||||
return fallback
|
||||
}
|
||||
return String(raw).trim()
|
||||
}
|
||||
|
||||
@@ -305,6 +305,35 @@ export async function fetchServiceIps(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVmIps(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
serviceId: string | number,
|
||||
vmId: string | number,
|
||||
): Promise<VeespIpItem[]> {
|
||||
const client = clientFor(baseUrl, credentials)
|
||||
try {
|
||||
const json = await client.request<unknown>(`/service/${serviceId}/vms/${vmId}/ips`)
|
||||
return unwrapList<VeespIpItem>(json, 'ips')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function mergeIpLists(...lists: VeespIpItem[][]): VeespIpItem[] {
|
||||
const seen = new Set<string>()
|
||||
const out: VeespIpItem[] = []
|
||||
for (const list of lists) {
|
||||
for (const item of list) {
|
||||
const ip = String(item.ip ?? item.address ?? item.ipaddress ?? '').trim()
|
||||
if (!ip || seen.has(ip)) continue
|
||||
seen.add(ip)
|
||||
out.push(item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function fetchServiceInfo(
|
||||
baseUrl: string,
|
||||
credentials: string,
|
||||
@@ -478,19 +507,25 @@ export async function fetchVpsRecords(
|
||||
const vmDetails = await Promise.all(
|
||||
vms.map(async (vm) => {
|
||||
const vmId = vm.id ?? vm.vmid
|
||||
if (vmId == null) return vm as VeespVmDetail
|
||||
const detail = await fetchVmDetail(baseUrl, credentials, serviceId, vmId)
|
||||
return { ...vm, ...detail } as VeespVmDetail
|
||||
if (vmId == null) return { vm: vm as VeespVmDetail, vmIps: [] as VeespIpItem[] }
|
||||
const [detail, vmIps] = await Promise.all([
|
||||
fetchVmDetail(baseUrl, credentials, serviceId, vmId),
|
||||
fetchVmIps(baseUrl, credentials, serviceId, vmId),
|
||||
])
|
||||
return {
|
||||
vm: { ...vm, ...detail } as VeespVmDetail,
|
||||
vmIps,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return vmDetails.map((vm) => ({
|
||||
return vmDetails.map(({ vm, vmIps }) => ({
|
||||
serviceId,
|
||||
vmId: String(vm.id ?? vm.vmid ?? vmExternalId(serviceId, vm)),
|
||||
service,
|
||||
serviceDetail,
|
||||
vm,
|
||||
ips,
|
||||
ips: mergeIpLists(ips, vmIps),
|
||||
info,
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -157,4 +157,83 @@ describe('syncFromVeesp', () => {
|
||||
.get('vps-veesp-acc-veesp-100-200') as { currency: string }
|
||||
expect(vps.currency).toBe('USD')
|
||||
})
|
||||
|
||||
it('updates specs and currency on existing VPS during re-sync', async () => {
|
||||
getSqlite().exec(`
|
||||
INSERT INTO vps (
|
||||
id, ip, ipv6, additionalIps, dns, providerId, providerAccountId,
|
||||
country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization,
|
||||
bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId,
|
||||
monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate,
|
||||
monthlyRate, createdAt, paidUntil, notes, userOverrides
|
||||
) VALUES (
|
||||
'vps-veesp-acc-veesp-100-200', '', '', '[]', 'rkn0', 'prov-veesp', 'acc-veesp',
|
||||
'', '', 'Proxmox', '', 0, 0, 0, 'NVMe', 'KVM',
|
||||
0, 22, 'root', '', '', '', NULL,
|
||||
0, 0, 'active', 'monthly', 'EUR', NULL,
|
||||
500, '2026-01-01', '2027-01-01', 'rkn0 [veesp-100]', '[]'
|
||||
)
|
||||
`)
|
||||
|
||||
vi.mocked(fetchVpsRecords).mockResolvedValue([
|
||||
{
|
||||
serviceId: '100',
|
||||
vmId: '200',
|
||||
service: {
|
||||
id: '100',
|
||||
domain: 'rkn0',
|
||||
total: '500.00',
|
||||
status: 'Active',
|
||||
billingcycle: 'Monthly',
|
||||
next_due: '2027-01-01',
|
||||
category: 'Proxmox',
|
||||
category_url: 'virtual-private-servers',
|
||||
name: 'VPS',
|
||||
},
|
||||
serviceDetail: {
|
||||
id: '100',
|
||||
total: '500.00',
|
||||
billingcycle: 'Monthly',
|
||||
next_due: '2027-01-01',
|
||||
status: 'Active',
|
||||
domain: 'rkn0',
|
||||
date_created: '2026-01-01',
|
||||
},
|
||||
vm: {
|
||||
id: '200',
|
||||
label: 'rkn0',
|
||||
hostname: 'rkn0',
|
||||
cpus: '2',
|
||||
memory: 2048,
|
||||
disk: 40,
|
||||
ip: ['198.51.100.5'],
|
||||
template_label: 'Debian 12',
|
||||
status: 'active',
|
||||
},
|
||||
ips: [{ ip: '198.51.100.5', main: true }],
|
||||
info: null,
|
||||
},
|
||||
])
|
||||
|
||||
await syncFromVeesp({
|
||||
...makeAccount(),
|
||||
currency: 'RUB',
|
||||
providerBaseCurrency: 'EUR',
|
||||
})
|
||||
|
||||
const vps = getSqlite()
|
||||
.prepare('SELECT ip, vcpu, ramGb, diskGb, currency FROM vps WHERE id = ?')
|
||||
.get('vps-veesp-acc-veesp-100-200') as {
|
||||
ip: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
currency: string
|
||||
}
|
||||
expect(vps.ip).toBe('198.51.100.5')
|
||||
expect(vps.vcpu).toBe(2)
|
||||
expect(vps.ramGb).toBe(2)
|
||||
expect(vps.diskGb).toBe(40)
|
||||
expect(vps.currency).toBe('RUB')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,8 @@ const SYNC_UPDATE_FIELDS = [
|
||||
'paidUntil',
|
||||
] as const
|
||||
|
||||
const SYNC_SPEC_FIELDS = ['vcpu', 'ramGb', 'diskGb'] as const
|
||||
|
||||
function normVal(v: unknown): string {
|
||||
if (v == null || v === '') return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
@@ -72,17 +74,20 @@ export async function syncFromVeesp(
|
||||
|
||||
const fetchVpsData = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
const fallbackCurrency = syncFallbackCurrency(account)
|
||||
|
||||
const [records, balanceInfo, tariffItems, invoices] = await Promise.all([
|
||||
fetchVpsData ? fetchVpsRecords(apiBaseUrl, credentials) : [],
|
||||
fetchVpsData
|
||||
? fetchBalance(apiBaseUrl, credentials, fallbackCurrency).catch(() => null)
|
||||
? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null)
|
||||
: null,
|
||||
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, fallbackCurrency).catch(() => []) : [],
|
||||
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => []) : [],
|
||||
fetchVpsData ? fetchInvoices(apiBaseUrl, credentials).catch(() => []) : [],
|
||||
])
|
||||
|
||||
const fallbackCurrency = syncFallbackCurrency(account, {
|
||||
balanceCurrency: balanceInfo?.currency,
|
||||
})
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
@@ -105,6 +110,7 @@ export async function syncFromVeesp(
|
||||
or(
|
||||
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
|
||||
like(schema.vps.notes, `%veesp-${vps.externalId}%`),
|
||||
like(schema.vps.notes, `%veesp-${record.serviceId}%`),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -126,6 +132,9 @@ export async function syncFromVeesp(
|
||||
city: vps.city,
|
||||
datacenter: vps.datacenter,
|
||||
os: vps.os,
|
||||
vcpu: vps.vcpu,
|
||||
ramGb: vps.ramGb,
|
||||
diskGb: vps.diskGb,
|
||||
status: vps.status,
|
||||
tariffType: vps.tariffType,
|
||||
currency: vps.currency,
|
||||
@@ -139,7 +148,18 @@ export async function syncFromVeesp(
|
||||
merged[f] = existing[f as keyof typeof existing] as never
|
||||
}
|
||||
}
|
||||
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
|
||||
for (const f of SYNC_SPEC_FIELDS) {
|
||||
if (userOverrides.includes(f)) {
|
||||
merged[f] = existing[f as keyof typeof existing] as never
|
||||
}
|
||||
}
|
||||
const compareFields = [
|
||||
'ip',
|
||||
'ipv6',
|
||||
'dns',
|
||||
...SYNC_SPEC_FIELDS,
|
||||
...SYNC_UPDATE_FIELDS,
|
||||
] as const
|
||||
const changedFields = compareFields.filter(
|
||||
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
||||
)
|
||||
@@ -157,6 +177,9 @@ export async function syncFromVeesp(
|
||||
city: merged.city,
|
||||
datacenter: merged.datacenter,
|
||||
os: merged.os,
|
||||
vcpu: merged.vcpu,
|
||||
ramGb: merged.ramGb,
|
||||
diskGb: merged.diskGb,
|
||||
status: merged.status,
|
||||
tariffType: merged.tariffType,
|
||||
currency: merged.currency,
|
||||
|
||||
@@ -145,7 +145,7 @@ export const providerAccountsRepository = {
|
||||
providerId: input.providerId ?? existing.providerId,
|
||||
name: input.name ?? existing.name,
|
||||
panelUrl: input.panelUrl ?? existing.panelUrl,
|
||||
currency: input.currency ?? existing.currency,
|
||||
currency: input.currency !== undefined ? input.currency : existing.currency,
|
||||
billingMode: input.billingMode ?? existing.billingMode,
|
||||
notes: input.notes ?? existing.notes,
|
||||
apiType: '',
|
||||
|
||||
@@ -32,10 +32,14 @@ export function effectiveAccountBalanceCurrency(
|
||||
}
|
||||
|
||||
export function syncFallbackCurrency(
|
||||
account: { currency?: string | null; providerBaseCurrency?: string | null },
|
||||
account: { currency?: string | null; providerBaseCurrency?: string | null; balanceCurrency?: string | null },
|
||||
options?: { balanceCurrency?: string | null },
|
||||
): string {
|
||||
return resolveProviderCurrency(
|
||||
{ baseCurrency: account.providerBaseCurrency },
|
||||
account.currency,
|
||||
)
|
||||
const accountCur = (account.currency ?? '').trim()
|
||||
if (accountCur) return accountCur
|
||||
const freshBalanceCur = (options?.balanceCurrency ?? '').trim()
|
||||
if (freshBalanceCur) return freshBalanceCur
|
||||
const storedBalanceCur = (account.balanceCurrency ?? '').trim()
|
||||
if (storedBalanceCur) return storedBalanceCur
|
||||
return resolveProviderCurrency({ baseCurrency: account.providerBaseCurrency }, null)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user