ograph/packages/cli/test/deploy.test.ts

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 the latest migration
expect(sqlFiles[sqlFiles.length - 1]).toBe('0022_projection_health.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')
})
})