- deploy: one-click OGraph deployment to Cloudflare (D1 + Worker) - cloudflare.ts: CF REST API client (verify, accounts, D1, Worker upload) - client.ts: auto-retry on CF 1042 edge propagation delay - readiness check: require 3 consecutive health OKs before declaring ready - 49 CLI tests passing (18 new deploy tests)
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
// deploy command tests — mock CF API
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { CloudflareClient } from '../src/cloudflare.js'
|
|
|
|
// ─── Mock fetch ────────────────────────────────────────────────────────────────
|
|
|
|
const mockFetch = vi.fn()
|
|
global.fetch = mockFetch
|
|
|
|
function cfOk<T>(result: T) {
|
|
return { ok: true, json: () => Promise.resolve({ success: true, result, errors: [], messages: [] }) }
|
|
}
|
|
|
|
function cfFail(code: number, message: string) {
|
|
return {
|
|
ok: false,
|
|
json: () => Promise.resolve({ success: false, result: null, errors: [{ code, message }], messages: [] }),
|
|
}
|
|
}
|
|
|
|
// ─── CloudflareClient Tests ────────────────────────────────────────────────────
|
|
|
|
describe('CloudflareClient', () => {
|
|
let client: CloudflareClient
|
|
|
|
beforeEach(() => {
|
|
client = new CloudflareClient('test-cf-token')
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
// ─── verifyToken ───────────────────────────────────────────────────────────
|
|
|
|
describe('verifyToken', () => {
|
|
it('should verify a valid token', async () => {
|
|
mockFetch.mockResolvedValue(cfOk({ id: 'token-123', status: 'active' }))
|
|
const result = await client.verifyToken()
|
|
expect(result.status).toBe('active')
|
|
expect(result.id).toBe('token-123')
|
|
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.cloudflare.com/client/v4/user/tokens/verify')
|
|
expect(opts.headers).toBeDefined()
|
|
const headers = opts.headers as Headers
|
|
expect(headers.get('Authorization')).toBe('Bearer test-cf-token')
|
|
})
|
|
|
|
it('should throw on invalid token', async () => {
|
|
mockFetch.mockResolvedValue(cfFail(1000, 'Invalid API Token'))
|
|
await expect(client.verifyToken()).rejects.toThrow('Cloudflare API error: [1000] Invalid API Token')
|
|
})
|
|
})
|
|
|
|
// ─── listAccounts ──────────────────────────────────────────────────────────
|
|
|
|
describe('listAccounts', () => {
|
|
it('should return accounts list', async () => {
|
|
mockFetch.mockResolvedValue(
|
|
cfOk([
|
|
{ id: 'acct-1', name: 'My Account' },
|
|
{ id: 'acct-2', name: 'Other Account' },
|
|
]),
|
|
)
|
|
const accounts = await client.listAccounts()
|
|
expect(accounts).toHaveLength(2)
|
|
expect(accounts[0]!.id).toBe('acct-1')
|
|
expect(accounts[1]!.name).toBe('Other Account')
|
|
expect(mockFetch.mock.calls[0]![0]).toBe('https://api.cloudflare.com/client/v4/accounts')
|
|
})
|
|
})
|
|
|
|
// ─── createD1Database ──────────────────────────────────────────────────────
|
|
|
|
describe('createD1Database', () => {
|
|
it('should create a D1 database', async () => {
|
|
mockFetch.mockResolvedValue(cfOk({ uuid: 'db-uuid-123', name: 'ograph' }))
|
|
const db = await client.createD1Database('acct-1', 'ograph')
|
|
expect(db.uuid).toBe('db-uuid-123')
|
|
expect(db.name).toBe('ograph')
|
|
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/d1/database')
|
|
expect(opts.method).toBe('POST')
|
|
expect(JSON.parse(opts.body as string)).toEqual({ name: 'ograph' })
|
|
})
|
|
|
|
it('should throw on permission error', async () => {
|
|
mockFetch.mockResolvedValue(cfFail(10000, 'Authentication error'))
|
|
await expect(client.createD1Database('acct-1', 'ograph')).rejects.toThrow(
|
|
'Cloudflare API error: [10000] Authentication error',
|
|
)
|
|
})
|
|
})
|
|
|
|
// ─── queryD1 ───────────────────────────────────────────────────────────────
|
|
|
|
describe('queryD1', () => {
|
|
it('should execute SQL against D1', async () => {
|
|
mockFetch.mockResolvedValue(cfOk([{ success: true, results: [], meta: { changes: 3 } }]))
|
|
const results = await client.queryD1('acct-1', 'db-uuid-123', 'CREATE TABLE test (id INTEGER);')
|
|
expect(results).toHaveLength(1)
|
|
expect(results[0]!.success).toBe(true)
|
|
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/d1/database/db-uuid-123/query')
|
|
expect(opts.method).toBe('POST')
|
|
expect(JSON.parse(opts.body as string)).toEqual({ sql: 'CREATE TABLE test (id INTEGER);' })
|
|
})
|
|
})
|
|
|
|
// ─── uploadWorker ──────────────────────────────────────────────────────────
|
|
|
|
describe('uploadWorker', () => {
|
|
it('should upload a worker script with bindings', async () => {
|
|
mockFetch.mockResolvedValue(cfOk({ id: 'ograph', etag: 'abc123' }))
|
|
const result = await client.uploadWorker('acct-1', 'ograph', 'export default { fetch() {} }', {
|
|
d1DatabaseId: 'db-uuid-123',
|
|
d1BindingName: 'DB',
|
|
apiToken: 'og_testtoken',
|
|
version: '2.4.0',
|
|
})
|
|
expect(result.id).toBe('ograph')
|
|
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/workers/scripts/ograph')
|
|
expect(opts.method).toBe('PUT')
|
|
|
|
// Verify multipart body contains metadata and script
|
|
const body = opts.body as string
|
|
expect(body).toContain('worker.js')
|
|
expect(body).toContain('export default { fetch() {} }')
|
|
expect(body).toContain('d1')
|
|
expect(body).toContain('db-uuid-123')
|
|
expect(body).toContain('og_testtoken')
|
|
expect(body).toContain('2.4.0')
|
|
|
|
// Verify content-type is multipart
|
|
const headers = opts.headers as Headers
|
|
expect(headers.get('Content-Type')).toContain('multipart/form-data')
|
|
})
|
|
|
|
it('should throw on upload failure', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
ok: false,
|
|
json: () =>
|
|
Promise.resolve({
|
|
success: false,
|
|
result: null,
|
|
errors: [{ code: 10007, message: 'Script too large' }],
|
|
messages: [],
|
|
}),
|
|
})
|
|
await expect(
|
|
client.uploadWorker('acct-1', 'ograph', 'code', {
|
|
d1DatabaseId: 'db-uuid-123',
|
|
d1BindingName: 'DB',
|
|
apiToken: 'og_test',
|
|
version: '2.4.0',
|
|
}),
|
|
).rejects.toThrow('Worker upload failed: [10007] Script too large')
|
|
})
|
|
})
|
|
|
|
// ─── getWorkersSubdomain ───────────────────────────────────────────────────
|
|
|
|
describe('getWorkersSubdomain', () => {
|
|
it('should return subdomain', async () => {
|
|
mockFetch.mockResolvedValue(cfOk({ subdomain: 'my-workers' }))
|
|
const subdomain = await client.getWorkersSubdomain('acct-1')
|
|
expect(subdomain).toBe('my-workers')
|
|
})
|
|
|
|
it('should return null on error', async () => {
|
|
mockFetch.mockResolvedValue(cfFail(10000, 'Not found'))
|
|
const subdomain = await client.getWorkersSubdomain('acct-1')
|
|
expect(subdomain).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ─── setWorkerCustomDomain ─────────────────────────────────────────────────
|
|
|
|
describe('setWorkerCustomDomain', () => {
|
|
it('should set custom domain', async () => {
|
|
mockFetch.mockResolvedValue(cfOk({ id: 'domain-1', hostname: 'api.example.com' }))
|
|
const result = await client.setWorkerCustomDomain('acct-1', 'ograph', 'api.example.com')
|
|
expect(result.hostname).toBe('api.example.com')
|
|
|
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
|
|
expect(url).toBe('https://api.cloudflare.com/client/v4/accounts/acct-1/workers/domains')
|
|
expect(opts.method).toBe('PUT')
|
|
const body = JSON.parse(opts.body as string)
|
|
expect(body.hostname).toBe('api.example.com')
|
|
expect(body.service).toBe('ograph')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ─── Migration Loading Tests ─────────────────────────────────────────────────
|
|
|
|
describe('loadMigrations (integration)', () => {
|
|
it('should find migration files in the ograph package', async () => {
|
|
const { readdir, readFile } = await import('node:fs/promises')
|
|
const { join } = await import('node:path')
|
|
|
|
const migrationsDir = join(import.meta.dirname ?? '.', '..', '..', 'engine', 'migrations')
|
|
const files = await readdir(migrationsDir)
|
|
const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort()
|
|
|
|
// Should have migration files
|
|
expect(sqlFiles.length).toBeGreaterThan(0)
|
|
|
|
// First file should be 0006_v2.sql
|
|
expect(sqlFiles[0]).toBe('0006_v2.sql')
|
|
|
|
// Last file should be 0021_request_logs.sql
|
|
expect(sqlFiles[sqlFiles.length - 1]).toBe('0021_request_logs.sql')
|
|
|
|
// Each file should contain SQL
|
|
for (const file of sqlFiles) {
|
|
const content = await readFile(join(migrationsDir, file), 'utf-8')
|
|
expect(content.length).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('should sort files in correct order', async () => {
|
|
const { readdir } = await import('node:fs/promises')
|
|
const { join } = await import('node:path')
|
|
|
|
const migrationsDir = join(import.meta.dirname ?? '.', '..', '..', 'engine', 'migrations')
|
|
const files = await readdir(migrationsDir)
|
|
const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort()
|
|
|
|
// Verify ordering by number prefix
|
|
for (let i = 1; i < sqlFiles.length; i++) {
|
|
const prevNum = parseInt(sqlFiles[i - 1]!.split('_')[0]!, 10)
|
|
const currNum = parseInt(sqlFiles[i]!.split('_')[0]!, 10)
|
|
expect(currNum).toBeGreaterThan(prevNum)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ─── Token Generation Tests ──────────────────────────────────────────────────
|
|
|
|
describe('generateApiToken', () => {
|
|
it('should generate tokens with og_ prefix', async () => {
|
|
const { randomBytes } = await import('node:crypto')
|
|
const token = `og_${randomBytes(32).toString('hex')}`
|
|
expect(token).toMatch(/^og_[a-f0-9]{64}$/)
|
|
})
|
|
})
|
|
|
|
// ─── Deploy Command Registration ────────────────────────────────────────────
|
|
|
|
describe('createDeployCommand', () => {
|
|
let createDeployCommand: () => import('commander').Command
|
|
|
|
beforeEach(async () => {
|
|
const mod = await import('../src/commands/deploy.js')
|
|
createDeployCommand = mod.createDeployCommand
|
|
})
|
|
|
|
it('should create a command named "deploy"', () => {
|
|
const cmd = createDeployCommand()
|
|
expect(cmd.name()).toBe('deploy')
|
|
})
|
|
|
|
it('should have all expected options', () => {
|
|
const cmd = createDeployCommand()
|
|
const optionNames = cmd.options.map((o) => o.long)
|
|
expect(optionNames).toContain('--cf-token')
|
|
expect(optionNames).toContain('--account-id')
|
|
expect(optionNames).toContain('--name')
|
|
expect(optionNames).toContain('--db-name')
|
|
expect(optionNames).toContain('--domain')
|
|
expect(optionNames).toContain('--yes')
|
|
})
|
|
|
|
it('should have correct defaults', () => {
|
|
const cmd = createDeployCommand()
|
|
const nameOpt = cmd.options.find((o) => o.long === '--name')
|
|
const dbNameOpt = cmd.options.find((o) => o.long === '--db-name')
|
|
expect(nameOpt?.defaultValue).toBe('ograph')
|
|
expect(dbNameOpt?.defaultValue).toBe('ograph')
|
|
})
|
|
|
|
it('should show description', () => {
|
|
const cmd = createDeployCommand()
|
|
expect(cmd.description()).toContain('Deploy')
|
|
})
|
|
})
|