fix(db): писать null в пустые FK и мигрировать старые БД без apiType
Пустая строка в vpsId/providerAccountId/providerId давала 500 SQLITE_CONSTRAINT_FOREIGNKEY при создании платежа, записи баланса и VPS без провайдера. Старые БД падали с «no such column apiType»: добавлены колонки providers.apiType/apiBaseUrl и перенос URL BILLmanager с аккаунтов. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a64d8b9755
commit
e37f69ebf0
7 files changed
+103
-16
No files matched your search
@@ -71,8 +71,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
|
||||
v.ipv6 ?? '',
|
||||
additionalIps,
|
||||
v.dns ?? '',
|
||||
v.providerId ?? '',
|
||||
v.providerAccountId ?? '',
|
||||
v.providerId || null,
|
||||
v.providerAccountId || null,
|
||||
v.country ?? '',
|
||||
v.city ?? '',
|
||||
v.datacenter ?? '',
|
||||
@@ -88,7 +88,7 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
|
||||
v.purpose ?? '',
|
||||
v.environment ?? '',
|
||||
v.project ?? '',
|
||||
v.projectId ?? null,
|
||||
v.projectId || null,
|
||||
v.monitoringEnabled ? 1 : 0,
|
||||
v.backupEnabled ? 1 : 0,
|
||||
v.status ?? 'active',
|
||||
@@ -116,8 +116,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
|
||||
pm.date ?? '',
|
||||
Number(pm.amount) || 0,
|
||||
pm.currency ?? '',
|
||||
pm.providerAccountId ?? '',
|
||||
pm.vpsId ?? '',
|
||||
pm.providerAccountId || null,
|
||||
pm.vpsId || null,
|
||||
pm.note ?? '',
|
||||
)
|
||||
}
|
||||
@@ -136,8 +136,8 @@ export const migrateRoutes: FastifyPluginAsync = async (app) => {
|
||||
Number(bl.amount) || 0,
|
||||
bl.currency ?? '',
|
||||
bl.direction ?? '',
|
||||
bl.providerAccountId ?? '',
|
||||
bl.vpsId ?? '',
|
||||
bl.providerAccountId || null,
|
||||
bl.vpsId || null,
|
||||
bl.note ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { buildApp } from '../index.js'
|
||||
|
||||
describe('payments / balance-ledger without VPS', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb()
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('POST /api/payments stores empty vpsId/providerAccountId as null', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/payments',
|
||||
payload: { type: 'topup', date: '2026-01-01', amount: 10, vpsId: '', providerAccountId: '' },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json() as { id: string; vpsId: string | null; providerAccountId: string | null }
|
||||
expect(body.vpsId).toBeNull()
|
||||
expect(body.providerAccountId).toBeNull()
|
||||
|
||||
const put = await app.inject({ method: 'PUT', url: `/api/payments/${body.id}`, payload: { note: 'x' } })
|
||||
expect(put.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('POST /api/balance-ledger stores empty refs as null', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/balance-ledger',
|
||||
payload: { type: 'topup', date: '2026-01-01', amount: 10, vpsId: '', providerAccountId: '' },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
})
|
||||
|
||||
it('POST /api/vps without provider does not hit FK', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/vps', payload: { ip: '10.0.0.1' } })
|
||||
expect(res.statusCode).toBeLessThan(300)
|
||||
})
|
||||
})
|
||||
@@ -17,8 +17,8 @@ function normalize(input: Partial<Row>) {
|
||||
amount: Number(input.amount) || 0,
|
||||
currency: input.currency ?? '',
|
||||
direction: input.direction ?? '',
|
||||
providerAccountId: input.providerAccountId ?? '',
|
||||
vpsId: input.vpsId ?? '',
|
||||
providerAccountId: input.providerAccountId || null,
|
||||
vpsId: input.vpsId || null,
|
||||
note: input.note ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ function normalize(input: Partial<Row>) {
|
||||
date: input.date ?? '',
|
||||
amount: Number(input.amount) || 0,
|
||||
currency: input.currency ?? '',
|
||||
providerAccountId: input.providerAccountId ?? '',
|
||||
vpsId: input.vpsId ?? '',
|
||||
providerAccountId: input.providerAccountId || null,
|
||||
vpsId: input.vpsId || null,
|
||||
note: input.note ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +189,8 @@ export const vpsRepository = {
|
||||
ipv6: input.ipv6 ?? '',
|
||||
additionalIps,
|
||||
dns: input.dns ?? '',
|
||||
providerId: input.providerId ?? '',
|
||||
providerAccountId: input.providerAccountId ?? '',
|
||||
providerId: input.providerId || null,
|
||||
providerAccountId: input.providerAccountId || null,
|
||||
country: input.country ?? '',
|
||||
city: input.city ?? '',
|
||||
datacenter: input.datacenter ?? '',
|
||||
@@ -292,8 +292,8 @@ export const vpsRepository = {
|
||||
ipv6: input.ipv6 ?? '',
|
||||
additionalIps,
|
||||
dns: input.dns ?? '',
|
||||
providerId: input.providerId ?? '',
|
||||
providerAccountId: input.providerAccountId ?? '',
|
||||
providerId: input.providerId || null,
|
||||
providerAccountId: input.providerAccountId || null,
|
||||
country: input.country ?? '',
|
||||
city: input.city ?? '',
|
||||
datacenter: input.datacenter ?? '',
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb, getDb, getSqlite } from './index.js'
|
||||
import { resetRuntimeMigrate } from './runtime-migrate.js'
|
||||
import { ensureRuntimeSchema, resetRuntimeMigrate } from './runtime-migrate.js'
|
||||
import Database from 'better-sqlite3'
|
||||
import { getSnapshot } from './repositories/snapshot.js'
|
||||
import { runWithSpace, MAIN_SPACE_ID } from './space-context.js'
|
||||
|
||||
@@ -42,3 +43,25 @@ describe('ensureRuntimeSchema (empty DB)', () => {
|
||||
expect(snap.settings.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRuntimeSchema (legacy DB without providers.apiType)', () => {
|
||||
afterEach(() => resetRuntimeMigrate())
|
||||
|
||||
it('adds provider API columns and moves BILLmanager URL from accounts', () => {
|
||||
const sqlite = new Database(':memory:')
|
||||
sqlite.exec(`CREATE TABLE providers (id TEXT PRIMARY KEY, name TEXT NOT NULL, website TEXT, contact TEXT, baseCurrency TEXT, usdRate TEXT, eurRate TEXT, notes TEXT)`)
|
||||
sqlite.exec(`CREATE TABLE provider_accounts (id TEXT PRIMARY KEY, providerId TEXT NOT NULL, name TEXT NOT NULL, panelUrl TEXT, currency TEXT, billingMode TEXT, notes TEXT, apiType TEXT, apiBaseUrl TEXT, apiCredentials TEXT)`)
|
||||
sqlite.exec(`INSERT INTO providers (id, name) VALUES ('p1', 'Host')`)
|
||||
sqlite.exec(`INSERT INTO provider_accounts (id, providerId, name, apiType, apiBaseUrl, apiCredentials) VALUES ('a1', 'p1', 'Acc', 'billmanager', 'https://bm.test/billmgr', 'u:p')`)
|
||||
|
||||
resetRuntimeMigrate()
|
||||
ensureRuntimeSchema(sqlite)
|
||||
|
||||
expect(sqlite.prepare(`SELECT apiType, apiBaseUrl, spaceId FROM providers`).get()).toEqual({
|
||||
apiType: 'billmanager',
|
||||
apiBaseUrl: 'https://bm.test/billmgr',
|
||||
spaceId: 'space-main',
|
||||
})
|
||||
expect(sqlite.prepare(`SELECT apiCredentials FROM provider_accounts`).get()).toEqual({ apiCredentials: 'u:p' })
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
import { consolidateAllProviderApiSources } from './maintenance/consolidate-api.js'
|
||||
|
||||
const MAIN_SPACE_ID = 'space-main'
|
||||
|
||||
@@ -405,6 +406,11 @@ const COLUMN_MIGRATIONS: string[] = [
|
||||
`ALTER TABLE censorcheck_runs ADD COLUMN detectedHoster TEXT`,
|
||||
]
|
||||
|
||||
const PROVIDER_API_MIGRATIONS: string[] = [
|
||||
`ALTER TABLE providers ADD COLUMN apiType TEXT`,
|
||||
`ALTER TABLE providers ADD COLUMN apiBaseUrl TEXT`,
|
||||
]
|
||||
|
||||
const SPACE_BACKFILL_TABLES = [
|
||||
'providers',
|
||||
'provider_accounts',
|
||||
@@ -536,6 +542,17 @@ export function ensureRuntimeSchema(sqlite: Database.Database): void {
|
||||
/* column exists */
|
||||
}
|
||||
}
|
||||
// Pre-provider-API DBs: columns appear empty, URLs still sit on accounts → move them once.
|
||||
let providerApiAdded = false
|
||||
for (const sql of PROVIDER_API_MIGRATIONS) {
|
||||
try {
|
||||
sqlite.exec(sql)
|
||||
providerApiAdded = true
|
||||
} catch {
|
||||
/* column exists */
|
||||
}
|
||||
}
|
||||
if (providerApiAdded) consolidateAllProviderApiSources(sqlite)
|
||||
rebuildVpsDomainsBindingUnique(sqlite)
|
||||
ensureMainSpace(sqlite)
|
||||
backfillSpaceIds(sqlite)
|
||||
|
||||
Reference in new issue
Block a user