diff --git a/.kilo/agent-manager.json b/.kilo/agent-manager.json new file mode 100644 index 0000000..4ed6e73 --- /dev/null +++ b/.kilo/agent-manager.json @@ -0,0 +1,7 @@ +{ + "worktrees": {}, + "sessions": {}, + "tabOrder": { + "local": [] + } +} \ No newline at end of file diff --git a/drizzle/migrations/0001_initial_down.sql b/drizzle/migrations/0001_initial_down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/drizzle/migrations/0001_initial_down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/drizzle/migrations/0001_initial_up.sql b/drizzle/migrations/0001_initial_up.sql new file mode 100644 index 0000000..73c821e --- /dev/null +++ b/drizzle/migrations/0001_initial_up.sql @@ -0,0 +1,4 @@ +-- Initial migration: creates the base users table. +-- Additional tables (sessions, tokens, domain tables, etc.) are generated by +-- subsequent migrations produced by `drizzle-kit generate`. +CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index bf8ba0e..48d4717 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -120,11 +120,19 @@ export async function runDevCommand(projectRoot: string) { // --- Graceful shutdown --- process.on("SIGINT", async () => { - await shutdown(); + try { + await shutdown(); + } catch (e) { + warn(`Shutdown error: ${e instanceof Error ? e.message : String(e)}`); + } process.exit(0); }); process.on("SIGTERM", async () => { - await shutdown(); + try { + await shutdown(); + } catch (e) { + warn(`Shutdown error: ${e instanceof Error ? e.message : String(e)}`); + } process.exit(0); }); diff --git a/packages/cli/src/commands/iac/analyze.ts b/packages/cli/src/commands/iac/analyze.ts index 9552a3c..04f0a94 100644 --- a/packages/cli/src/commands/iac/analyze.ts +++ b/packages/cli/src/commands/iac/analyze.ts @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { extname, join } from "node:path"; import * as logger from "../../utils/logger"; @@ -16,7 +16,7 @@ export async function runIacAnalyze( ): Promise { const betterbaseDir = join(projectRoot, "betterbase"); - if (!statSync(betterbaseDir).isDirectory()) { + if (!existsSync(betterbaseDir) || !statSync(betterbaseDir).isDirectory()) { logger.error("No betterbase/ directory found. Run this from a BetterBase project."); return; } @@ -27,7 +27,7 @@ export async function runIacAnalyze( const results: QueryAnalysis[] = []; for (const q of queries) { - const analysis = analyzeQuery(q); + const analysis = analyzeQuery(q, betterbaseDir); results.push(analysis); } @@ -48,10 +48,11 @@ interface QueryAnalysis { function scanQueries(betterbaseDir: string): string[] { const queriesDir = join(betterbaseDir, "queries"); - const files: string[] = []; - + if (!existsSync(queriesDir)) return []; if (!statSync(queriesDir).isDirectory()) return []; + const files: string[] = []; + function walk(dir: string) { for (const entry of readdirSync(dir)) { const fullPath = join(dir, entry); @@ -67,9 +68,9 @@ function scanQueries(betterbaseDir: string): string[] { return files; } -function analyzeQuery(filePath: string): QueryAnalysis { +function analyzeQuery(filePath: string, betterbaseDir: string): QueryAnalysis { const content = readFileSync(filePath, "utf-8"); - const path = filePath.replace(join(process.cwd(), "betterbase/"), ""); + const path = relative(betterbaseDir, filePath); const issues: string[] = []; const suggestions: string[] = []; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 28fef29..699558d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { generateDrizzleConfig } from "@betterbase/core/config"; @@ -10,8 +11,32 @@ import { generateEnvContent, promptForProvider } from "../utils/provider-prompts /** * Copy the IaC template to the target directory */ -async function copyIaCTemplate(targetDir: string): Promise { - const templateDir = path.join(import.meta.dir, "..", "..", "..", "templates", "iac"); +async function copyIaCTemplate(targetDir: string, projectName: string): Promise { + // Try multiple possible template locations to support both development and production scenarios + const possibleTemplatePaths = [ + // When installed globally and templates are copied to dist/templates (production) + path.join(import.meta.dir, "..", "..", "..", "..", "templates", "iac"), + // When running from built monorepo (packages/cli/dist -> betterbase/templates) + path.join(import.meta.dir, "..", "..", "..", "..", "..", "betterbase", "templates", "iac"), + // When running from monorepo source with one level of nesting + path.join(import.meta.dir, "..", "..", "..", "..", "..", "..", "betterbase", "templates", "iac"), + // When running from monorepo source with betterbase/ subdirectory + path.join(import.meta.dir, "..", "..", "..", "..", "..", "..", "..", "betterbase", "templates", "iac"), + ]; + + let templateDir: string | null = null; + for (const testPath of possibleTemplatePaths) { + if (existsSync(testPath)) { + templateDir = testPath; + break; + } + } + + if (!templateDir) { + throw new Error( + `IaC template not found. Searched:\n${possibleTemplatePaths.map((p) => ` - ${p}`).join("\n")}` + ); + } // Check if template exists try { @@ -63,11 +88,25 @@ async function copyIaCTemplate(targetDir: string): Promise { try { const content = await readFile(srcPath); await writeFile(destPath, content); - } catch { - // Skip if file doesn't exist + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") { + throw new Error(`Missing IaC template file: ${srcPath}`); + } + throw error; } } + // Inject the user-supplied project name into the copied package.json + const pkgPath = path.join(targetDir, "package.json"); + try { + const pkgJson = JSON.parse(await readFile(pkgPath, "utf-8")); + pkgJson.name = projectName; + await writeFile(pkgPath, `${JSON.stringify(pkgJson, null, 2)}\n`); + } catch { + // package.json absent from template — safe to skip + } + // Create .env file with multi-provider support await writeFile( path.join(targetDir, ".env"), @@ -183,7 +222,7 @@ function getAuthDialect(provider: ProviderType): "sqlite" | "pg" | "mysql" { } async function installDependencies(projectPath: string): Promise { - const installProcess = Bun.spawn(["bun", "install"], { + const installProcess = Bun.spawn([process.execPath, "install"], { cwd: projectPath, stdout: "inherit", stderr: "inherit", @@ -471,7 +510,7 @@ try { const sqlite = new Database(env.DB_PATH, { create: true }); const db = drizzle(sqlite); - migrate(db, { migrationsFolder: './drizzle' }); + await migrate(db, { migrationsFolder: './drizzle' }); console.log('Migrations applied successfully.'); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -855,8 +894,57 @@ const s3 = new S3Client({ ${endpointLine} }); -const BUCKET = process.env.STORAGE_BUCKET ?? '' -`; +const BUCKET = process.env.STORAGE_BUCKET ?? ''; + +export const storageRoute = new Hono(); + +// TODO: Replace with your production auth middleware before deploying +storageRoute.use('*', async (c, next) => { + // Import auth middleware dynamically to avoid circular dependencies + try { + const { requireAuth } = await import('./middleware/auth'); + return requireAuth(c, next); + } catch (e) { + // Fallback to simple bearer check for development + const authHeader = c.req.header('authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + // In a real app, you would verify the token here + // For now, we'll just pass it through but log a warning + console.warn('Using fallback auth - replace with proper token verification'); + await next(); + } +}); + +storageRoute.put('/:key', async (c) => { + const key = c.req.param('key'); + const body = await c.req.arrayBuffer(); + await s3.send(new PutObjectCommand({ + Bucket: BUCKET, + Key: key, + Body: Buffer.from(body), + })); + return c.json({ ok: true }); +}); + +storageRoute.get('/:key', async (c) => { + const key = c.req.param('key'); + const url = await getSignedUrl(s3, new GetObjectCommand({ + Bucket: BUCKET, + Key: key, + }), { expiresIn: 3600 }); + return c.json({ url }); +}); + +storageRoute.delete('/:key', async (c) => { + const key = c.req.param('key'); + await s3.send(new DeleteObjectCommand({ + Bucket: BUCKET, + Key: key, + })); + return c.json({ ok: true }); +});`; } async function writeProjectFiles( @@ -1347,7 +1435,7 @@ export async function runInitCommand(rawOptions: InitCommandOptions): Promise process.env.BB_CREDENTIALS_DIR || join(homedir(), ".betterbase"); +const CREDENTIALS_FILE = () => join(getCredentialsDir(), "credentials.json"); const CredentialsSchema = z.object({ token: z.string(), @@ -16,16 +17,17 @@ const CredentialsSchema = z.object({ export type Credentials = z.infer; export function saveCredentials(creds: Credentials): void { + const CREDENTIALS_DIR = getCredentialsDir(); if (!existsSync(CREDENTIALS_DIR)) { mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); } - writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); + writeFileSync(CREDENTIALS_FILE(), JSON.stringify(creds, null, 2), { mode: 0o600 }); } export function loadCredentials(): Credentials | null { - if (!existsSync(CREDENTIALS_FILE)) return null; + if (!existsSync(CREDENTIALS_FILE())) return null; try { - const raw = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")); + const raw = JSON.parse(readFileSync(CREDENTIALS_FILE(), "utf-8")); return CredentialsSchema.parse(raw); } catch { return null; @@ -33,12 +35,13 @@ export function loadCredentials(): Credentials | null { } export function clearCredentials(): void { - if (existsSync(CREDENTIALS_FILE)) { - writeFileSync(CREDENTIALS_FILE, JSON.stringify({})); + if (existsSync(CREDENTIALS_FILE())) { + writeFileSync(CREDENTIALS_FILE(), JSON.stringify({})); } } export function getServerUrl(): string { const creds = loadCredentials(); - return creds?.server_url ?? "https://api.betterbase.io"; // Falls back to cloud + let url = creds?.server_url ?? "https://api.betterbase.io"; + return url.replace(/\/+$/, ""); // Remove trailing slashes } diff --git a/packages/cli/src/utils/route-scanner.ts b/packages/cli/src/utils/route-scanner.ts index 2984219..eedfa00 100644 --- a/packages/cli/src/utils/route-scanner.ts +++ b/packages/cli/src/utils/route-scanner.ts @@ -63,6 +63,19 @@ function collectTsFiles(dir: string): string[] { return files; } +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression; + while ( + ts.isParenthesizedExpression(current) || + ts.isAsExpression(current) || + ts.isSatisfiesExpression(current) + ) { + // @ts-ignore – the expression property exists on these node types + current = current.expression; + } + return current; +} + export class RouteScanner { scan(routesDir: string): Record { const files = collectTsFiles(routesDir); @@ -91,19 +104,7 @@ export class RouteScanner { const routes: Record = {}; const authIdentifiers = new Set(); - const isAuthMiddlewareExpression = (expr: ts.Expression): boolean => { - if (ts.isIdentifier(expr)) { - return authIdentifiers.has(expr.text) || isAuthLikeName(expr.text); - } - - if (ts.isPropertyAccessExpression(expr)) { - const text = expr.getText(sourceFile); - return isAuthLikeName(text); - } - - return false; - }; - + // ── Collect auth identifiers (unchanged) ─────────────────────────────────── const collectAuthIdentifiers = (node: ts.Node): void => { if (!ts.isVariableStatement(node)) return; @@ -127,13 +128,100 @@ export class RouteScanner { ts.forEachChild(sourceFile, collectAuthIdentifiers); + // ── Collect group definitions for nested route prefixing ──────────────────── + const groupParent: Record = {}; // child var -> parent var + const groupPath: Record = {}; // var -> its path segment + + const collectGroups = (node: ts.Node): void => { + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; + // Unwrap to get actual call expression (handle parentheses) + const init = unwrapExpression(decl.initializer); + if (ts.isCallExpression(init)) { + const callExpr = init as ts.CallExpression; + // Check if the callee is a property access with name "group" + const callee = callExpr.expression; + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "group" + ) { + // parent is the object on which .group() is called + const parentExpr = callee.expression; + let parentName = ""; + if (ts.isIdentifier(parentExpr)) { + parentName = parentExpr.text; + } + // Extract group path from first argument + const pathArg = callExpr.arguments[0]; + const pathStr = getStringLiteral(pathArg); + groupParent[decl.name.text] = parentName; + groupPath[decl.name.text] = pathStr; + } + } + } + } + ts.forEachChild(node, collectGroups); + }; + + collectGroups(sourceFile); + + // Helper: compute full prefix for a router variable by following parent chain + const getFullPrefix = (varName: string): string => { + let prefix = ""; + let current = varName; + const visited = new Set(); + while (groupParent[current] !== undefined && !visited.has(current)) { + visited.add(current); + const parent = groupParent[current]; + const segment = groupPath[current] || ""; + // Ensure segment starts with '/' + const seg = segment.startsWith("/") ? segment : "/" + segment; + prefix = seg + prefix; + current = parent; + } + return prefix; + }; + + // ── Extract route definitions ───────────────────────────────────────────── + const isAuthMiddlewareExpression = (expr: ts.Expression): boolean => { + if (ts.isIdentifier(expr)) { + return authIdentifiers.has(expr.text) || isAuthLikeName(expr.text); + } + if (ts.isPropertyAccessExpression(expr)) { + const text = expr.getText(sourceFile); + return isAuthLikeName(text); + } + return false; + }; + const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.text.toLowerCase(); if (httpMethods.has(method)) { const [pathArg, ...handlerArgs] = node.arguments; - const routePath = getStringLiteral(pathArg); + const basePath = getStringLiteral(pathArg); + + // Determine the router variable this method is called on + const routerExpr = node.expression.expression; // the object before .method + let routerVar: string | null = null; + if (ts.isIdentifier(routerExpr)) { + routerVar = routerExpr.text; + } else if (ts.isPropertyAccessExpression(routerExpr)) { + // Handle deeper chaining (unlikely): recursively get identifier + let cur: ts.Node = routerExpr; + while (ts.isPropertyAccessExpression(cur)) { + if (ts.isIdentifier(cur.expression)) { + routerVar = cur.expression.text; + break; + } + cur = cur.expression; + } + } + + const prefix = routerVar ? getFullPrefix(routerVar) : ""; + const fullPath = prefix + basePath; let requiresAuth = false; for (const arg of handlerArgs) { @@ -145,17 +233,17 @@ export class RouteScanner { const route: RouteInfo = { method: method.toUpperCase(), - path: routePath, + path: fullPath, requiresAuth, inputSchema: this.findSchemaUsage(sourceFile, handlerArgs, "input"), outputSchema: this.findSchemaUsage(sourceFile, handlerArgs, "output"), }; - if (!routes[routePath]) { - routes[routePath] = []; + if (!routes[fullPath]) { + routes[fullPath] = []; } - routes[routePath].push(route); + routes[fullPath].push(route); } } diff --git a/packages/cli/src/utils/scanner.ts b/packages/cli/src/utils/scanner.ts index b6b3e51..62f6009 100644 --- a/packages/cli/src/utils/scanner.ts +++ b/packages/cli/src/utils/scanner.ts @@ -22,6 +22,12 @@ export const ColumnInfoSchema = z.object({ primaryKey: z.boolean(), defaultValue: z.string().optional(), references: z.string().optional(), + // Raw Drizzle type method name (e.g., 'text', 'varchar', 'integer') + dataType: z.string().optional(), + // Array modifier + array: z.boolean().optional(), + // Enum values if column uses .enum() + enum: z.array(z.string()).optional(), }); export const TableInfoSchema = z.object({ @@ -121,7 +127,8 @@ export class SchemaScanner { functionName === "mysqlTable" ) { const tableObj = this.parseTable(initializer); - const tableKey = tableObj.name || declaration.name.text; + // Use the variable name as the key, per spec (easier for codegen) + const tableKey = declaration.name.text; tables[tableKey] = tableObj; } } @@ -278,40 +285,51 @@ export class SchemaScanner { private parseColumn(columnName: string, expression: ts.Expression): ColumnInfo { let type: ColumnInfo["type"] = "unknown"; + let dataType: string | undefined = undefined; // raw type method name let nullable = true; let unique = false; let primaryKey = false; let defaultValue: string | undefined; let references: string | undefined; + let array = false; + let enumValues: string[] | undefined = undefined; let current = unwrapExpression(expression); while (ts.isCallExpression(current)) { const methodName = getCallName(current); - if (methodName === "text" || methodName === "varchar" || methodName === "char") { + // Type methods: set both dataType and simplified type + if (!dataType && (methodName === "text" || methodName === "varchar" || methodName === "char")) { + dataType = methodName; type = "text"; - } else if ( + } else if (!dataType && ( methodName === "integer" || methodName === "int" || methodName === "bigint" || methodName === "serial" - ) { + )) { + dataType = methodName; type = "integer"; - } else if ( + } else if (!dataType && ( methodName === "real" || methodName === "numeric" || methodName === "decimal" || methodName === "doublePrecision" - ) { + )) { + dataType = methodName; type = "number"; - } else if (methodName === "boolean") { + } else if (!dataType && methodName === "boolean") { + dataType = methodName; type = "boolean"; - } else if (methodName === "timestamp" || methodName === "datetime") { + } else if (!dataType && (methodName === "timestamp" || methodName === "datetime")) { + dataType = methodName; type = "datetime"; - } else if (methodName === "json" || methodName === "jsonb") { + } else if (!dataType && (methodName === "json" || methodName === "jsonb")) { + dataType = methodName; type = "json"; - } else if (methodName === "blob") { + } else if (!dataType && methodName === "blob") { + dataType = methodName; type = "blob"; } else if (methodName === "notNull") { nullable = false; @@ -324,6 +342,21 @@ export class SchemaScanner { defaultValue = getExpressionText(this.sourceFile, current.arguments[0]); } else if (methodName === "references") { references = getExpressionText(this.sourceFile, current.arguments[0]); + } else if (methodName === "array") { + array = true; + } else if (methodName === "enum") { + // Extract enum values from first argument (array literal) + if (current.arguments.length > 0) { + const arg = current.arguments[0]; + if (ts.isArrayLiteralExpression(arg)) { + enumValues = arg.elements.map(el => { + if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) { + return el.text; + } + return el.getText(this.sourceFile); + }); + } + } } if (ts.isPropertyAccessExpression(current.expression)) { @@ -342,6 +375,9 @@ export class SchemaScanner { primaryKey, defaultValue, references, + dataType, + array, + enum: enumValues, }; } } diff --git a/packages/cli/test/auth-commands.test.ts b/packages/cli/test/auth-commands.test.ts deleted file mode 100644 index 298b033..0000000 --- a/packages/cli/test/auth-commands.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Auth CLI Commands Test Suite - * - * Tests for untested auth command functions in cli/src/commands/auth.ts - */ - -import { describe, expect, it } from "bun:test"; - -describe("Auth CLI Commands", () => { - describe("runAuthSetupCommand", () => { - it("should setup authentication", async () => { - expect(true).toBe(true); - }); - - it("should configure session provider", async () => { - expect(true).toBe(true); - }); - - it("should handle existing auth setup", async () => { - expect(true).toBe(true); - }); - - it("should generate required files", async () => { - expect(true).toBe(true); - }); - }); - - describe("runAuthAddProviderCommand", () => { - it("should add authentication provider", async () => { - expect(true).toBe(true); - }); - - it("should validate provider type", async () => { - expect(true).toBe(true); - }); - - it("should require provider configuration", async () => { - expect(true).toBe(true); - }); - - it("should handle duplicate provider", async () => { - expect(true).toBe(true); - }); - - it("should update auth configuration", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Placeholder tests -describe("Auth CLI Command Stubs", () => { - it("should have placeholder for setup", () => { - const config = { session: "cookie", providers: ["email"] }; - expect(config.session).toBe("cookie"); - }); - - it("should have placeholder for addProvider", () => { - const provider = { type: "github", clientId: "xxx" }; - expect(provider.type).toBe("github"); - }); -}); diff --git a/packages/cli/test/branch-commands.test.ts b/packages/cli/test/branch-commands.test.ts deleted file mode 100644 index 127b975..0000000 --- a/packages/cli/test/branch-commands.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Branch Commands Test Suite - * - * Tests for untested branch command functions in cli/src/commands/branch.ts - */ - -import { describe, expect, it } from "bun:test"; -import { EventEmitter } from "node:events"; - -describe("Branch Commands", () => { - describe("runBranchCreateCommand", () => { - it("should require branch name argument", async () => { - // The function should exit when no name is provided - // This is tested indirectly by checking the behavior - expect(true).toBe(true); - }); - - it("should handle missing config file", async () => { - // This would test error handling when BetterBase config is not found - expect(true).toBe(true); - }); - - it("should create branch with valid name", async () => { - // This would test successful branch creation - expect(true).toBe(true); - }); - }); - - describe("runBranchListCommand", () => { - it("should list all branches", async () => { - expect(true).toBe(true); - }); - - it("should handle empty branches", async () => { - expect(true).toBe(true); - }); - }); - - describe("runBranchDeleteCommand", () => { - it("should require branch name", async () => { - expect(true).toBe(true); - }); - - it("should delete existing branch", async () => { - expect(true).toBe(true); - }); - - it("should handle non-existent branch", async () => { - expect(true).toBe(true); - }); - }); - - describe("runBranchSleepCommand", () => { - it("should put branch to sleep", async () => { - expect(true).toBe(true); - }); - - it("should handle already sleeping branch", async () => { - expect(true).toBe(true); - }); - }); - - describe("runBranchWakeCommand", () => { - it("should wake sleeping branch", async () => { - expect(true).toBe(true); - }); - - it("should handle already active branch", async () => { - expect(true).toBe(true); - }); - }); - - describe("runBranchCommand", () => { - it("should route to correct subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show help when no subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show error for unknown subcommand", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Simple stub tests to ensure test infrastructure works -describe("Branch Command Stubs", () => { - it("should have placeholder tests for runBranchCreateCommand", () => { - const branchName = "test-branch"; - expect(branchName).toBe("test-branch"); - }); - - it("should have placeholder tests for runBranchListCommand", () => { - const branches = ["main", "develop"]; - expect(branches.length).toBe(2); - }); - - it("should have placeholder tests for runBranchDeleteCommand", () => { - const result = { success: true }; - expect(result.success).toBe(true); - }); - - it("should have placeholder tests for runBranchSleepCommand", () => { - const status = "sleeping"; - expect(status).toBe("sleeping"); - }); - - it("should have placeholder tests for runBranchWakeCommand", () => { - const status = "active"; - expect(status).toBe("active"); - }); -}); diff --git a/packages/cli/test/cli/cli-parsing.test.ts b/packages/cli/test/cli/cli-parsing.test.ts new file mode 100644 index 0000000..adc02b7 --- /dev/null +++ b/packages/cli/test/cli/cli-parsing.test.ts @@ -0,0 +1,673 @@ +import { describe, expect, it } from "bun:test"; +import { CommanderError } from "commander"; +import { createProgram } from "../../src/index"; + +function findCmd(parent: ReturnType, name: string) { + return parent.commands.find((c) => c.name() === name) as ReturnType | undefined; +} + +function findArg(cmd: ReturnType, name: string) { + return cmd.registeredArguments.find((a) => a.name() === name); +} + +function findOpt(cmd: ReturnType, name: string) { + const longName = name.startsWith("--") ? name : `--${name}`; + return cmd.options.find((o) => o.name() === name || o.long === longName); +} + +describe("CLI argument parsing regression", () => { + describe("top-level program", () => { + const program = createProgram(); + + it("has name 'bb'", () => { + expect(program.name()).toBe("bb"); + }); + + it("has --debug option", () => { + const opt = findOpt(program, "debug"); + expect(opt).toBeDefined(); + expect(opt?.long).toBe("--debug"); + }); + + it("has --version option", () => { + const opt = findOpt(program, "version"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-v"); + expect(opt?.long).toBe("--version"); + }); + + it("uses .exitOverride() for CommanderError instead of process.exit", () => { + expect(program.exitOverride).toBeDefined(); + }); + }); + + describe("init", () => { + const program = createProgram(); + const init = findCmd(program, "init")!; + + it("registers init command", () => { + expect(init).toBeDefined(); + }); + + it("has optional project-name argument", () => { + const arg = findArg(init, "project-name"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + + it("has --no-iac option", () => { + const opt = findOpt(init, "no-iac"); + expect(opt).toBeDefined(); + expect(opt?.long).toBe("--no-iac"); + }); + }); + + describe("auth", () => { + const program = createProgram(); + const auth = findCmd(program, "auth")!; + + it("registers auth command", () => { + expect(auth).toBeDefined(); + }); + + describe("auth setup", () => { + const setup = findCmd(auth, "setup")!; + + it("registers setup subcommand", () => { + expect(setup).toBeDefined(); + }); + + it("has optional project-root argument with cwd default", () => { + const arg = findArg(setup, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + expect(arg?.defaultValue).toBeDefined(); + }); + }); + + describe("auth add-provider", () => { + const addProvider = findCmd(auth, "add-provider")!; + + it("registers add-provider subcommand", () => { + expect(addProvider).toBeDefined(); + }); + + it("has required provider argument", () => { + const arg = findArg(addProvider, "provider"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has optional project-root argument", () => { + const arg = findArg(addProvider, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + }); + + describe("generate", () => { + const program = createProgram(); + const generate = findCmd(program, "generate")!; + + it("registers generate command", () => { + expect(generate).toBeDefined(); + }); + + describe("generate crud", () => { + const crud = findCmd(generate, "crud")!; + + it("registers crud subcommand", () => { + expect(crud).toBeDefined(); + }); + + it("has required table-name argument", () => { + const arg = findArg(crud, "table-name"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has optional project-root argument", () => { + const arg = findArg(crud, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + }); + + describe("graphql", () => { + const program = createProgram(); + const graphql = findCmd(program, "graphql")!; + + it("registers graphql command", () => { + expect(graphql).toBeDefined(); + }); + + describe("graphql generate", () => { + const gqlGen = findCmd(graphql, "generate")!; + + it("registers generate subcommand", () => { + expect(gqlGen).toBeDefined(); + }); + + it("has optional project-root argument", () => { + const arg = findArg(gqlGen, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + + describe("graphql playground", () => { + const playground = findCmd(graphql, "playground")!; + + it("registers playground subcommand", () => { + expect(playground).toBeDefined(); + }); + }); + }); + + describe("iac", () => { + const program = createProgram(); + const iac = findCmd(program, "iac")!; + + it("registers iac command", () => { + expect(iac).toBeDefined(); + }); + + describe("iac sync", () => { + const sync = findCmd(iac, "sync")!; + + it("registers sync subcommand", () => { + expect(sync).toBeDefined(); + }); + + it("has optional project-root argument", () => { + const arg = findArg(sync, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + + it("has --force option", () => { + const opt = findOpt(sync, "force"); + expect(opt).toBeDefined(); + expect(opt?.long).toBe("--force"); + }); + }); + + describe("iac analyze", () => { + const analyze = findCmd(iac, "analyze")!; + + it("registers analyze subcommand", () => { + expect(analyze).toBeDefined(); + }); + + it("has -o, --output option with default 'table'", () => { + const opt = findOpt(analyze, "output"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-o"); + expect(opt?.long).toBe("--output"); + expect(opt?.defaultValue).toBe("table"); + }); + + it("has optional project-root argument", () => { + const arg = findArg(analyze, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + + describe("iac export", () => { + const exp = findCmd(iac, "export")!; + + it("registers export subcommand", () => { + expect(exp).toBeDefined(); + }); + + it("has -f, --format option with default 'json'", () => { + const opt = findOpt(exp, "format"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-f"); + expect(opt?.long).toBe("--format"); + expect(opt?.defaultValue).toBe("json"); + }); + + it("has -o, --output option with default './backup'", () => { + const opt = findOpt(exp, "output"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-o"); + expect(opt?.long).toBe("--output"); + expect(opt?.defaultValue).toBe("./backup"); + }); + + it("has -t, --table option", () => { + const opt = findOpt(exp, "table"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-t"); + expect(opt?.long).toBe("--table"); + }); + + it("has optional project-root argument", () => { + const arg = findArg(exp, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + + describe("iac import", () => { + const imp = findCmd(iac, "import")!; + + it("registers import subcommand", () => { + expect(imp).toBeDefined(); + }); + + it("has required input argument", () => { + const arg = findArg(imp, "input"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has -t, --table option", () => { + const opt = findOpt(imp, "table"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-t"); + expect(opt?.long).toBe("--table"); + }); + + it("has -d, --dry-run option", () => { + const opt = findOpt(imp, "dry-run"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-d"); + expect(opt?.long).toBe("--dry-run"); + }); + }); + }); + + describe("migrate", () => { + const program = createProgram(); + const migrate = findCmd(program, "migrate")!; + + it("registers migrate command", () => { + expect(migrate).toBeDefined(); + }); + + describe("migrate preview", () => { + const preview = findCmd(migrate, "preview")!; + + it("registers preview subcommand", () => { + expect(preview).toBeDefined(); + }); + }); + + describe("migrate production", () => { + const production = findCmd(migrate, "production")!; + + it("registers production subcommand", () => { + expect(production).toBeDefined(); + }); + }); + + describe("migrate rollback", () => { + const rollback = findCmd(migrate, "rollback")!; + + it("registers rollback subcommand", () => { + expect(rollback).toBeDefined(); + }); + + it("has -s, --steps option with default '1'", () => { + const opt = findOpt(rollback, "steps"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-s"); + expect(opt?.long).toBe("--steps"); + expect(opt?.defaultValue).toBe("1"); + }); + }); + + describe("migrate from-convex", () => { + const fromConvex = findCmd(migrate, "from-convex")!; + + it("registers from-convex subcommand", () => { + expect(fromConvex).toBeDefined(); + }); + + it("has required input-path argument", () => { + const arg = findArg(fromConvex, "input-path"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has -o, --output option with default './migrated'", () => { + const opt = findOpt(fromConvex, "output"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-o"); + expect(opt?.long).toBe("--output"); + expect(opt?.defaultValue).toBe("./migrated"); + }); + }); + }); + + describe("storage", () => { + const program = createProgram(); + const storage = findCmd(program, "storage")!; + + it("registers storage command", () => { + expect(storage).toBeDefined(); + }); + + describe("storage init", () => { + const init = findCmd(storage, "init")!; + + it("registers init subcommand", () => { + expect(init).toBeDefined(); + }); + + it("has optional project-root argument", () => { + const arg = findArg(init, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + + describe("storage upload", () => { + const upload = findCmd(storage, "upload")!; + + it("registers upload subcommand", () => { + expect(upload).toBeDefined(); + }); + + it("has required file argument", () => { + const arg = findArg(upload, "file"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has -b, --bucket option", () => { + const opt = findOpt(upload, "bucket"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-b"); + expect(opt?.long).toBe("--bucket"); + }); + + it("has -p, --path option", () => { + const opt = findOpt(upload, "path"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-p"); + expect(opt?.long).toBe("--path"); + }); + + it("has -r, --root option with cwd default", () => { + const opt = findOpt(upload, "root"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-r"); + expect(opt?.long).toBe("--root"); + expect(opt?.defaultValue).toBeDefined(); + }); + }); + }); + + describe("rls", () => { + const program = createProgram(); + const rls = findCmd(program, "rls")!; + + it("registers rls command", () => { + expect(rls).toBeDefined(); + }); + + describe("rls create", () => { + const create = findCmd(rls, "create")!; + + it("registers create subcommand", () => { + expect(create).toBeDefined(); + }); + + it("has required table argument", () => { + const arg = findArg(create, "table"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + }); + + describe("rls disable", () => { + const disable = findCmd(rls, "disable")!; + + it("registers disable subcommand", () => { + expect(disable).toBeDefined(); + }); + + it("has required table argument", () => { + const arg = findArg(disable, "table"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + }); + }); + + describe("webhook", () => { + const program = createProgram(); + const webhook = findCmd(program, "webhook")!; + + it("registers webhook command", () => { + expect(webhook).toBeDefined(); + }); + + describe("webhook create", () => { + const create = findCmd(webhook, "create")!; + + it("registers create subcommand", () => { + expect(create).toBeDefined(); + }); + + it("has optional project-root argument", () => { + const arg = findArg(create, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + + describe("webhook test", () => { + const test = findCmd(webhook, "test")!; + + it("registers test subcommand", () => { + expect(test).toBeDefined(); + }); + + it("has required webhook-id argument", () => { + const arg = findArg(test, "webhook-id"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has optional project-root argument", () => { + const arg = findArg(test, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + + describe("webhook logs", () => { + const logs = findCmd(webhook, "logs")!; + + it("registers logs subcommand", () => { + expect(logs).toBeDefined(); + }); + + it("has required webhook-id argument", () => { + const arg = findArg(logs, "webhook-id"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has -l, --limit option with default '50'", () => { + const opt = findOpt(logs, "limit"); + expect(opt).toBeDefined(); + expect(opt?.short).toBe("-l"); + expect(opt?.long).toBe("--limit"); + expect(opt?.defaultValue).toBe("50"); + }); + }); + }); + + describe("function", () => { + const program = createProgram(); + const fn = findCmd(program, "function")!; + + it("registers function command", () => { + expect(fn).toBeDefined(); + }); + + describe("function create", () => { + const create = findCmd(fn, "create")!; + + it("registers create subcommand", () => { + expect(create).toBeDefined(); + }); + + it("has required name argument", () => { + const arg = findArg(create, "name"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has optional project-root argument", () => { + const arg = findArg(create, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + + describe("function deploy", () => { + const deploy = findCmd(fn, "deploy")!; + + it("registers deploy subcommand", () => { + expect(deploy).toBeDefined(); + }); + + it("has required name argument", () => { + const arg = findArg(deploy, "name"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has optional project-root argument", () => { + const arg = findArg(deploy, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + + it("has --sync-env option", () => { + const opt = findOpt(deploy, "sync-env"); + expect(opt).toBeDefined(); + expect(opt?.long).toBe("--sync-env"); + }); + }); + }); + + describe("branch", () => { + const program = createProgram(); + const branch = findCmd(program, "branch")!; + + it("registers branch command", () => { + expect(branch).toBeDefined(); + }); + + describe("branch create", () => { + const create = findCmd(branch, "create")!; + + it("registers create subcommand", () => { + expect(create).toBeDefined(); + }); + + it("has required name argument", () => { + const arg = findArg(create, "name"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(true); + }); + + it("has optional project-root argument", () => { + const arg = findArg(create, "project-root"); + expect(arg).toBeDefined(); + expect(arg?.required).toBe(false); + }); + }); + }); + + describe("login", () => { + const program = createProgram(); + const login = findCmd(program, "login")!; + + it("registers login command", () => { + expect(login).toBeDefined(); + }); + + it("has --url option with default 'https://api.betterbase.io'", () => { + const opt = findOpt(login, "url"); + expect(opt).toBeDefined(); + expect(opt?.long).toBe("--url"); + expect(opt?.defaultValue).toBe("https://api.betterbase.io"); + }); + + it("has --email option", () => { + const opt = findOpt(login, "email"); + expect(opt).toBeDefined(); + expect(opt?.long).toBe("--email"); + }); + }); + + describe("help text", () => { + const program = createProgram(); + + it("contains expected usage info", () => { + const help = program.helpInformation(); + expect(help).toContain("bb"); + expect(help).toContain("BetterBase CLI"); + }); + + it("lists init command", () => { + const help = program.helpInformation(); + expect(help).toContain("init"); + }); + + it("lists migrate command", () => { + const help = program.helpInformation(); + expect(help).toContain("migrate"); + }); + }); + + describe("parseAsync --help", () => { + it("throws CommanderError with code commander.helpDisplayed", async () => { + const program = createProgram(); + try { + await program.parseAsync(["node", "bb", "--help"]); + throw new Error("Expected CommanderError to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CommanderError); + expect((err as CommanderError).code).toBe("commander.helpDisplayed"); + } + }); + }); + + describe("parseAsync --version", () => { + it("throws CommanderError with code commander.version", async () => { + const program = createProgram(); + try { + await program.parseAsync(["node", "bb", "--version"]); + throw new Error("Expected CommanderError to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CommanderError); + expect((err as CommanderError).code).toBe("commander.version"); + } + }); + }); + + describe("parseAsync unknown command", () => { + it("throws CommanderError for unrecognized subcommand", async () => { + const program = createProgram(); + try { + await program.parseAsync(["node", "bb", "unknown-command"]); + throw new Error("Expected CommanderError to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(CommanderError); + expect((err as CommanderError).code).toBe("commander.unknownCommand"); + } + }); + }); +}); diff --git a/packages/cli/test/dev.test.ts b/packages/cli/test/dev.test.ts index 8b8b0a0..96305e6 100644 --- a/packages/cli/test/dev.test.ts +++ b/packages/cli/test/dev.test.ts @@ -1,63 +1,389 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { mkdirSync } from "node:fs"; import path from "node:path"; +import { createTestProject } from "./fixtures/fixtures"; -let tmpDir: string; +// ── Mock state tracking ──────────────────────────────────────────────────────────── +let processManagerStarted = false; +let processManagerStopped = false; +let processManagerRestartCount = 0; +let watcherStarted = false; +let watcherStopped = false; +let queryLogEnabled = false; +let queryLogDisabled = false; +let contextGenerated = false; +let iacSyncCalled = false; +let iacSyncShouldThrow = false; +let iacSyncError = ""; +let iacGenerateCalled = false; +let iacGenerateShouldThrow = false; +let iacGenerateError = ""; -beforeAll(() => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "betterbase-test-")); -}); +function resetMockState() { + processManagerStarted = false; + processManagerStopped = false; + processManagerRestartCount = 0; + watcherStarted = false; + watcherStopped = false; + queryLogEnabled = false; + queryLogDisabled = false; + contextGenerated = false; + iacSyncCalled = false; + iacSyncShouldThrow = false; + iacSyncError = ""; + iacGenerateCalled = false; + iacGenerateShouldThrow = false; + iacGenerateError = ""; +} + +// ── Env backup / restore ────────────────────────────────────────────────────────── +function saveEnv() { + const orig: Record = {}; + for (const key of ["QUERY_LOG", "NODE_ENV"]) { + orig[key] = process.env[key]; + } + return orig; +} + +function restoreEnv(orig: Record) { + for (const key of Object.keys(orig)) { + if (orig[key] !== undefined) { + process.env[key] = orig[key]; + } else { + delete process.env[key]; + } + } +} + +// ── Mocks ───────────────────────────────────────────────────────────────────────── +const processManagerPath = path.resolve(__dirname, "../src/commands/dev/process-manager.ts"); +const watcherPath = path.resolve(__dirname, "../src/commands/dev/watcher.ts"); +const queryLogPath = path.resolve(__dirname, "../src/commands/dev/query-log.ts"); +const contextGenPath = path.resolve(__dirname, "../src/utils/context-generator.ts"); +const iacGenPath = path.resolve(__dirname, "../src/commands/iac/generate.ts"); +const iacSyncPath = path.resolve(__dirname, "../src/commands/iac/sync.ts"); + +mock.module(processManagerPath, () => ({ + ProcessManager: class { + async start() { + processManagerStarted = true; + } + async stop() { + processManagerStopped = true; + } + async restart(_reason: string) { + processManagerRestartCount++; + } + }, +})); + +mock.module(watcherPath, () => ({ + DevWatcher: class { + on(_handler: unknown) { + return this; + } + start(_projectRoot: string) { + watcherStarted = true; + } + stop() { + watcherStopped = true; + } + }, +})); -afterAll(() => { - rmSync(tmpDir, { recursive: true, force: true }); +mock.module(queryLogPath, () => { + const queryLogMock = { + enable() { + queryLogEnabled = true; + }, + disable() { + queryLogDisabled = true; + }, + log(_entry: unknown) {}, + getEntries() { + return []; + }, + clear() {}, + }; + return { queryLog: queryLogMock, QueryLog: class {} }; }); -describe("project directory structure", () => { - it("creates project structure for dev server", async () => { - const testDir = mkdtempSync(path.join(os.tmpdir(), "bb-dev-structure-")); - - mkdirSync(path.join(testDir, "src/db"), { recursive: true }); - mkdirSync(path.join(testDir, "src/routes"), { recursive: true }); - writeFileSync( - path.join(testDir, "src/index.ts"), - ` -import { Hono } from "hono" -const app = new Hono() -export default { port: 0, fetch: app.fetch } -`, - ); - writeFileSync(path.join(testDir, "src/db/schema.ts"), "export const schema = {}"); - - expect(existsSync(path.join(testDir, "src/index.ts"))).toBe(true); - expect(existsSync(path.join(testDir, "src/db/schema.ts"))).toBe(true); - rmSync(testDir, { recursive: true, force: true }); - }); - - it("handles missing src/index.ts gracefully", async () => { - const testDir = mkdtempSync(path.join(os.tmpdir(), "bb-dev-missing-")); - - expect(existsSync(path.join(testDir, "src/index.ts"))).toBe(false); - rmSync(testDir, { recursive: true, force: true }); - }); - - it("validates project directory creation", async () => { - const testDir = mkdtempSync(path.join(os.tmpdir(), "bb-dev-validate-")); - - mkdirSync(path.join(testDir, "src/db"), { recursive: true }); - mkdirSync(path.join(testDir, "src/routes"), { recursive: true }); - writeFileSync( - path.join(testDir, "src/index.ts"), - ` -import { Hono } from "hono" -const app = new Hono() -export default { port: 0, fetch: app.fetch } -`, - ); - writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "test" })); - - expect(existsSync(path.join(testDir, "src/index.ts"))).toBe(true); - expect(existsSync(path.join(testDir, "package.json"))).toBe(true); - rmSync(testDir, { recursive: true, force: true }); +mock.module(contextGenPath, () => ({ + ContextGenerator: class { + async generate(_projectRoot: string) { + contextGenerated = true; + return {}; + } + }, +})); + +mock.module(iacGenPath, () => ({ + runIacGenerate: async () => { + if (iacGenerateShouldThrow) { + throw new Error(iacGenerateError || "IAC generate failure"); + } + iacGenerateCalled = true; + }, +})); + +mock.module(iacSyncPath, () => ({ + runIacSync: async () => { + if (iacSyncShouldThrow) { + throw new Error(iacSyncError || "IAC sync failure"); + } + iacSyncCalled = true; + }, +})); + +// ── Dynamic import after mocks registered ───────────────────────────────────────── +const { runDevCommand } = await import("../src/commands/dev"); + +// ═══════════════════════════════════════════════════════════════════════════════════ +describe("runDevCommand", () => { + let envBackup: ReturnType; + let baselineSIGINT: Function[]; + let baselineSIGTERM: Function[]; + + beforeEach(() => { + resetMockState(); + envBackup = saveEnv(); + delete process.env.QUERY_LOG; + process.env.NODE_ENV = "test"; + baselineSIGINT = process.listeners("SIGINT") as Function[]; + baselineSIGTERM = process.listeners("SIGTERM") as Function[]; + }); + + afterEach(() => { + // Remove only handlers added during the test + const currentSIGINT = process.listeners("SIGINT") as Function[]; + const currentSIGTERM = process.listeners("SIGTERM") as Function[]; + for (const fn of currentSIGINT) { + if (!baselineSIGINT.includes(fn)) { + process.removeListener("SIGINT", fn); + } + } + for (const fn of currentSIGTERM) { + if (!baselineSIGTERM.includes(fn)) { + process.removeListener("SIGTERM", fn); + } + } + restoreEnv(envBackup); + }); + + // 1 ────────────────────────────────────────────────────────────────────────────── + it("is a callable async function", () => { + expect(runDevCommand).toBeFunction(); + expect(runDevCommand.constructor.name).toBe("AsyncFunction"); + }); + + // 2 ────────────────────────────────────────────────────────────────────────────── + it("returns a cleanup function", async () => { + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(cleanup).toBeFunction(); + + await cleanup(); + project.cleanup(); + }); + + // 3 ────────────────────────────────────────────────────────────────────────────── + it("cleanup function resolves without error", async () => { + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + await expect(cleanup()).resolves.toBeUndefined(); + + project.cleanup(); + }); + + // 4 ────────────────────────────────────────────────────────────────────────────── + it("starts ProcessManager when invoked", async () => { + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(processManagerStarted).toBe(true); + + await cleanup(); + project.cleanup(); + }); + + // 5 ────────────────────────────────────────────────────────────────────────────── + it("starts DevWatcher when invoked", async () => { + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(watcherStarted).toBe(true); + + await cleanup(); + project.cleanup(); + }); + + // 6 ────────────────────────────────────────────────────────────────────────────── + it("skips IAC sync and generate when no betterbase/ directory", async () => { + const project = createTestProject({ + "package.json": JSON.stringify({ name: "test" }), + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(iacSyncCalled).toBe(false); + expect(iacGenerateCalled).toBe(false); + expect(processManagerStarted).toBe(true); + + await cleanup(); + project.cleanup(); + }); + + // 7 ────────────────────────────────────────────────────────────────────────────── + it("calls IAC sync and generate when betterbase/ directory exists", async () => { + const project = createTestProject({ + "package.json": JSON.stringify({ name: "test" }), + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + "betterbase/schema.ts": "export default {};\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(iacSyncCalled).toBe(true); + expect(iacGenerateCalled).toBe(true); + + await cleanup(); + project.cleanup(); + }); + + // 8 ────────────────────────────────────────────────────────────────────────────── + it("does not crash when IAC sync throws an error", async () => { + iacSyncShouldThrow = true; + iacSyncError = "Schema parse error"; + + const project = createTestProject({ + "package.json": JSON.stringify({ name: "test" }), + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + "betterbase/schema.ts": "export default {};\n", + }); + + const cleanup = await runDevCommand(project.root); + + // Should not crash — sync failure is caught and warned + expect(processManagerStarted).toBe(true); + expect(watcherStarted).toBe(true); + + await cleanup(); + project.cleanup(); + }); + + // 9 ────────────────────────────────────────────────────────────────────────────── + it("does not crash when IAC generate throws an error", async () => { + iacGenerateShouldThrow = true; + iacGenerateError = "Generation failure"; + + const project = createTestProject({ + "package.json": JSON.stringify({ name: "test" }), + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + "betterbase/schema.ts": "export default {};\n", + }); + + const cleanup = await runDevCommand(project.root); + + // IAC sync should still have been called before generate + expect(iacSyncCalled).toBe(true); + // Should not crash — generate failure is caught and warned + expect(processManagerStarted).toBe(true); + expect(watcherStarted).toBe(true); + + await cleanup(); + project.cleanup(); + }); + + // 10 ───────────────────────────────────────────────────────────────────────────── + it("enables query log when QUERY_LOG=true", async () => { + process.env.QUERY_LOG = "true"; + + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(queryLogEnabled).toBe(true); + + await cleanup(); + + expect(queryLogDisabled).toBe(true); + + project.cleanup(); + }); + + // 11 ───────────────────────────────────────────────────────────────────────────── + it("does not enable query log when QUERY_LOG is unset", async () => { + delete process.env.QUERY_LOG; + + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(queryLogEnabled).toBe(false); + + await cleanup(); + project.cleanup(); + }); + + // 12 ───────────────────────────────────────────────────────────────────────────── + it("cleanup stops ProcessManager and DevWatcher", async () => { + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + await cleanup(); + + expect(processManagerStopped).toBe(true); + expect(watcherStopped).toBe(true); + expect(queryLogDisabled).toBe(true); + + project.cleanup(); + }); + + // 13 ───────────────────────────────────────────────────────────────────────────── + it("accepts projectRoot with a nonexistent path gracefully", async () => { + const cleanup = await runDevCommand("/nonexistent/path/12345"); + + expect(cleanup).toBeFunction(); + // Should still start the process manager and watcher + expect(processManagerStarted).toBe(true); + expect(watcherStarted).toBe(true); + + await cleanup(); + }); + + // 14 ───────────────────────────────────────────────────────────────────────────── + it("generates context on startup", async () => { + const project = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(project.root); + + expect(contextGenerated).toBe(true); + + await cleanup(); + project.cleanup(); }); }); diff --git a/packages/cli/test/e2e/binary-smoke.test.ts b/packages/cli/test/e2e/binary-smoke.test.ts new file mode 100644 index 0000000..8db1958 --- /dev/null +++ b/packages/cli/test/e2e/binary-smoke.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const CLI_DIR = join(import.meta.dir, "..", ".."); +const DIST_DIR = join(CLI_DIR, "dist"); + +function runCommand(cmd: string, args: string[] = []): { exitCode: number; stdout: string; stderr: string } { + const executable = cmd === "bun" ? process.execPath : cmd; + const resolvedArgs = cmd === "bun" ? args : args; + const result = spawnSync(executable, resolvedArgs, { + cwd: CLI_DIR, + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", + }); + if (result.error) { + return { + exitCode: 1, + stdout: "", + stderr: result.error.message ?? "spawn error", + }; + } + return { + exitCode: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function ensureBuilt(): void { + if (!existsSync(DIST_DIR)) { + const build = runCommand("bun", ["run", "build"]); + if (build.exitCode !== 0) { + throw new Error(`Build failed:\n${build.stderr}`); + } + } +} + +describe("binary smoke tests", () => { + it("can build the CLI", () => { + const result = runCommand("bun", ["run", "build"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("error"); + }); + + it("bb --version exits with 0 and stdout contains version", () => { + ensureBuilt(); + const result = runCommand("bun", ["run", "dist/index.js", "--version"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + }); + + it("bb --help exits with 0 and stdout contains subcommand list", () => { + ensureBuilt(); + const result = runCommand("bun", ["run", "dist/index.js", "--help"]); + expect(result.exitCode).toBe(0); + const out = result.stdout + result.stderr; + expect(out).toContain("Commands:"); + expect(out).toContain("init"); + expect(out).toContain("dev"); + expect(out).toContain("login"); + }); + + it("bb init --help exits 0 and contains usage", () => { + ensureBuilt(); + const result = runCommand("bun", ["run", "dist/index.js", "init", "--help"]); + expect(result.exitCode).toBe(0); + const out = result.stdout + result.stderr; + expect(out).toContain("Usage:"); + expect(out).toContain("init"); + }); + + it("bb unknown-command exits non-zero", () => { + ensureBuilt(); + const result = runCommand("bun", ["run", "dist/index.js", "not-a-real-command-xyz"]); + expect(result.exitCode).not.toBe(0); + const out = result.stdout + result.stderr; + expect(out.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/test/fixtures/config.ts b/packages/cli/test/fixtures/config.ts new file mode 100644 index 0000000..e200e19 --- /dev/null +++ b/packages/cli/test/fixtures/config.ts @@ -0,0 +1,55 @@ +import { createTestProject } from "./fixtures"; + +export const VALID_CONFIG_TS = ` +import { defineConfig } from "@betterbase/core"; + +export default defineConfig({ + project: { name: "test-project" }, + provider: { + type: "sqlite" as const, + connectionString: "local.db", + }, + storage: { + provider: "s3" as const, + bucket: "test-bucket", + region: "us-east-1", + }, + webhooks: [], +}); +`; + +export const CONFIG_WITH_WEBHOOKS = ` +import { defineConfig } from "@betterbase/core"; + +export default defineConfig({ + project: { name: "test-project" }, + webhooks: [ + { + id: "webhook-abc123", + table: "users", + events: ["INSERT", "UPDATE"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ], +}); +`; + +export const INVALID_CONFIG_TS = ` +export default { + project: { name: "test-project" }, + provider: { + type: "invalid-provider", + }, +}; +`; + +export function createConfigProject( + configContent: string = VALID_CONFIG_TS, +) { + return createTestProject({ + "betterbase.config.ts": configContent, + "package.json": JSON.stringify({ name: "test-project" }), + }); +} diff --git a/packages/cli/test/fixtures/credentials.ts b/packages/cli/test/fixtures/credentials.ts new file mode 100644 index 0000000..5bf85cb --- /dev/null +++ b/packages/cli/test/fixtures/credentials.ts @@ -0,0 +1,45 @@ +import { join } from "node:path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; + +const BETTERBASE_DIR = join(homedir(), ".betterbase"); +const CREDENTIALS_FILE = join(BETTERBASE_DIR, "credentials.json"); + +export interface CredentialFixture { + token: string; + admin_email: string; + server_url: string; + created_at: string; +} + +export function setupCredentialsFile( + credentials: CredentialFixture, +): () => void { + mkdirSync(BETTERBASE_DIR, { recursive: true }); + writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials)); + + return () => { + if (existsSync(CREDENTIALS_FILE)) { + rmSync(CREDENTIALS_FILE); + } + }; +} + +export function createValidCredentials(): CredentialFixture { + return { + token: `token_${randomUUID()}`, + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; +} + +export function createExpiredCredentials(): CredentialFixture { + return { + token: "expired_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(), + }; +} diff --git a/packages/cli/test/fixtures/database.ts b/packages/cli/test/fixtures/database.ts new file mode 100644 index 0000000..c24bf66 --- /dev/null +++ b/packages/cli/test/fixtures/database.ts @@ -0,0 +1,84 @@ +import { Database } from "bun:sqlite"; + +export interface TestDatabase { + db: Database; + cleanup: () => void; +} + +export function createTestDatabase(): TestDatabase { + const db = new Database(":memory:"); + + db.run(` + CREATE TABLE IF NOT EXISTS _betterbase_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checksum TEXT NOT NULL + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS _betterbase_webhook_deliveries ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'pending')), + request_url TEXT NOT NULL, + request_body TEXT, + response_code INTEGER, + response_body TEXT, + error TEXT, + attempt_count INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + return { + db, + cleanup: () => db.close(), + }; +} + +export function seedMigrationTracking( + db: Database, + migrations: { name: string; checksum: string }[], +): void { + const stmt = db.prepare( + "INSERT INTO _betterbase_migrations (name, checksum) VALUES (?, ?)", + ); + for (const m of migrations) { + stmt.run(m.name, m.checksum); + } +} + +export function seedWebhookDeliveries( + db: Database, + deliveries: { + id: string; + webhook_id: string; + status: "success" | "failed" | "pending"; + response_code?: number; + error?: string; + request_url: string; + request_body?: string; + response_body?: string; + }[], +): void { + const stmt = db.prepare( + `INSERT INTO _betterbase_webhook_deliveries + (id, webhook_id, status, request_url, request_body, response_code, response_body, error, attempt_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)`, + ); + for (const d of deliveries) { + stmt.run( + d.id, + d.webhook_id, + d.status, + d.request_url, + d.request_body ?? null, + d.response_code ?? null, + d.response_body ?? null, + d.error ?? null, + ); + } +} diff --git a/packages/cli/test/fixtures/fetch-mock.ts b/packages/cli/test/fixtures/fetch-mock.ts new file mode 100644 index 0000000..c371ccb --- /dev/null +++ b/packages/cli/test/fixtures/fetch-mock.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { mkdirSync, writeFileSync } from "node:fs"; + +export interface MockFetchRoute { + method?: string; + url: string | RegExp; + status: number; + body: unknown; + headers?: Record; +} + +export function mockFetch( + routes: MockFetchRoute[], +): typeof globalThis.fetch & { calls: Request[] } { + const calls: Request[] = []; + + const mock = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const request = new Request(input instanceof Request ? input : url, init); + calls.push(request); + + for (const route of routes) { + const urlMatch = + typeof route.url === "string" + ? url.includes(route.url) + : route.url.test(url); + const methodMatch = !route.method || route.method === method; + + if (urlMatch && methodMatch) { + return new Response(JSON.stringify(route.body), { + status: route.status, + headers: { + "Content-Type": "application/json", + ...route.headers, + }, + }); + } + } + + return new Response(JSON.stringify({ error: "unmocked" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + (mock as unknown as { calls: Request[] }).calls = calls; + return mock as typeof globalThis.fetch & { calls: Request[] }; +} + +export const DEVICE_CODE_RESPONSE = { + device_code: "device_code_abc123", + user_code: "ABCD-EFGH", + verification_uri: "https://api.betterbase.io/device", +}; + +export const TOKEN_RESPONSE_PENDING = { error: "authorization_pending" }; + +export const TOKEN_RESPONSE_SUCCESS = { + access_token: "access_token_xyz789", +}; + +export const ADMIN_ME_RESPONSE = { + admin: { email: "admin@test.com" }, +}; + +export const ADMIN_LOGIN_RESPONSE = { + token: "api_key_token_123", + admin: { email: "admin@test.com" }, +}; + +export const ADMIN_LOGIN_ERROR = { error: "Invalid credentials" }; + +export function setupCredentialsFile(credentials: { token: string; admin_email: string; server_url: string }) { + const credentialsDir = process.env.BB_CREDENTIALS_DIR || join(tmpdir(), ".betterbase"); + mkdirSync(credentialsDir, { recursive: true }); + const credentialsFile = join(credentialsDir, "credentials.json"); + writeFileSync( + credentialsFile, + JSON.stringify({ + token: credentials.token, + admin_email: credentials.admin_email, + server_url: credentials.server_url, + created_at: new Date().toISOString(), + }), + "utf-8", + ); + return credentialsFile; +} + +export function createValidCredentials() { + return { + token: "token_test123", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; +} + +export function createExpiredCredentials() { + return { + token: "expired_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // 24 hours ago + }; +} diff --git a/packages/cli/test/fixtures/fixtures.ts b/packages/cli/test/fixtures/fixtures.ts new file mode 100644 index 0000000..aaf8aec --- /dev/null +++ b/packages/cli/test/fixtures/fixtures.ts @@ -0,0 +1,89 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +export interface TestProject { + root: string; + cleanup: () => void; +} + +export function createTestProject(files?: Record): TestProject { + const root = join(tmpdir(), `bb-test-${randomUUID().slice(0, 8)}`); + mkdirSync(root, { recursive: true }); + + if (files) { + for (const [relPath, content] of Object.entries(files)) { + const absPath = join(root, relPath); + mkdirSync(join(absPath, ".."), { recursive: true }); + writeFileSync(absPath, content); + } + } + + return { + root, + cleanup: () => { + try { rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ } + }, + }; +} + +export function createMinimalSchema(): string { + return ` +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + age: integer("age"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), +}); + +export const posts = sqliteTable("posts", { + id: text("id").primaryKey(), + title: text("title").notNull(), + content: text("content"), + userId: text("user_id").references(() => users.id), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), +}); +`; +} + +export function createMinimalConfig(overrides?: Record): string { + return ` +import { defineConfig } from "@betterbase/core"; + +export default defineConfig({ + project: { name: "test-project" }, + ${overrides ? JSON.stringify(overrides, null, 2).slice(1, -1) : ""} +}); +`; +} + +export const SIMPLE_SCHEMA = createMinimalSchema(); + +export const MULTI_TABLE_SCHEMA = ` +import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; + +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + name: text('name').notNull(), +}); + +export const posts = sqliteTable('posts', { + id: text('id').primaryKey(), + title: text('title').notNull(), + content: text('content'), + userId: text('user_id').notNull().references(() => users.id), + published: integer('published', { mode: 'boolean' }).default(0), +}); + +export const comments = sqliteTable('comments', { + id: text('id').primaryKey(), + body: text('body').notNull(), + postId: text('post_id').notNull().references(() => posts.id), + userId: text('user_id').notNull().references(() => users.id), +}); +`; diff --git a/packages/cli/test/function-commands.test.ts b/packages/cli/test/function-commands.test.ts deleted file mode 100644 index f55023c..0000000 --- a/packages/cli/test/function-commands.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Function CLI Commands Test Suite - * - * Tests for untested function command functions in cli/src/commands/function.ts - */ - -import { describe, expect, it } from "bun:test"; - -describe("Function CLI Commands", () => { - describe("runFunctionCommand", () => { - it("should route to correct subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show help when no subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show error for unknown subcommand", async () => { - expect(true).toBe(true); - }); - - it("should deploy function", async () => { - expect(true).toBe(true); - }); - - it("should list functions", async () => { - expect(true).toBe(true); - }); - - it("should invoke function", async () => { - expect(true).toBe(true); - }); - }); - - describe("stopAllFunctions", () => { - it("should stop all running functions", async () => { - expect(true).toBe(true); - }); - - it("should handle no running functions", async () => { - expect(true).toBe(true); - }); - - it("should cleanup resources", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Placeholder tests -describe("Function CLI Command Stubs", () => { - it("should have placeholder for deploy", () => { - const func = { name: "hello", runtime: "nodejs" }; - expect(func.name).toBe("hello"); - }); - - it("should have placeholder for list", () => { - const funcs = [{ name: "func1" }, { name: "func2" }]; - expect(funcs.length).toBe(2); - }); - - it("should have placeholder for invoke", () => { - const result = { output: "Hello, World!" }; - expect(result.output).toBe("Hello, World!"); - }); - - it("should have placeholder for stopAllFunctions", () => { - const stopped = 0; - expect(stopped).toBe(0); - }); -}); diff --git a/packages/cli/test/graphql-type-map.test.ts b/packages/cli/test/graphql-type-map.test.ts index c65ba10..20cc666 100644 --- a/packages/cli/test/graphql-type-map.test.ts +++ b/packages/cli/test/graphql-type-map.test.ts @@ -4,46 +4,49 @@ * Tests for the chain code maps in graphql.ts CLI command: * - typeMap: Maps Drizzle column types to GraphQL types * - drizzleTypeToGraphQL(): Converts Drizzle types to GraphQL type strings + * + * Note: drizzleTypeToGraphQL is NOT exported from src/commands/graphql.ts. This test file includes the implementation and verifies it via source file comparison tests. */ import { describe, expect, it } from "bun:test"; - -// Import the function to test - we'll test the logic directly -// This is the typeMap and drizzleTypeToGraphQL from graphql.ts +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; /** * Map Drizzle column types to GraphQL types - * This is the typeMap from graphql.ts CLI command + * This is the typeMap from graphql.ts CLI command (duplicated locally since + * the function is not exported from src/commands/graphql.ts). */ -function drizzleTypeToGraphQL(drizzleType: string): string { - const typeMap: Record = { - integer: "Int", - int: "Int", - smallint: "Int", - bigint: "Int", - real: "Float", - double: "Float", - float: "Float", - numeric: "Float", - decimal: "Float", - boolean: "Boolean", - bool: "Boolean", - text: "String", - varchar: "String", - char: "String", - uuid: "ID", - timestamp: "DateTime", - timestamptz: "DateTime", - datetime: "DateTime", - date: "DateTime", - json: "JSON", - jsonb: "JSON", - blob: "String", - bytea: "String", - }; +const LOCAL_TYPE_MAP: Record = { + integer: "Int", + int: "Int", + smallint: "Int", + bigint: "Int", + real: "Float", + double: "Float", + float: "Float", + numeric: "Float", + decimal: "Float", + boolean: "Boolean", + bool: "Boolean", + text: "String", + varchar: "String", + char: "String", + uuid: "ID", + timestamp: "DateTime", + timestamptz: "DateTime", + datetime: "DateTime", + date: "DateTime", + json: "JSON", + jsonb: "JSON", + blob: "String", + bytea: "String", +}; +function drizzleTypeToGraphQL(drizzleType: string): string { const lowerType = drizzleType.toLowerCase(); - return typeMap[lowerType] || "String"; + return LOCAL_TYPE_MAP[lowerType] || "String"; } describe("CLI GraphQL Type Map - drizzleTypeToGraphQL", () => { @@ -495,3 +498,42 @@ describe("CLI GraphQL Type Map - typeMap completeness", () => { }); }); }); + +describe("CLI GraphQL Type Map - Source File Comparison", () => { + it("should match the typeMap in src/commands/graphql.ts exactly", () => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const sourcePath = path.join(__dirname, "..", "src", "commands", "graphql.ts"); + const source = readFileSync(sourcePath, "utf-8"); + + // Extract the typeMap object from inside the drizzleTypeToGraphQL function + // The typeMap is defined as: const typeMap: Record = { ... }; + const typeMapRegex = /const\s+typeMap\s*:\s*Record\s*=\s*{([\s\S]*?)};/; + const match = source.match(typeMapRegex); + + if (!match) { + throw new Error("Could not find typeMap definition in source file"); + } + + const typeMapBody = match[1]; + + // Parse key-value pairs from the typeMap body + const parsedSourceTypeMap: Record = {}; + const entryRegex = /(\w+)\s*:\s*"([^"]+)"/g; + let entryMatch; + while ((entryMatch = entryRegex.exec(typeMapBody)) !== null) { + const key = entryMatch[1]; + const value = entryMatch[2]; + parsedSourceTypeMap[key] = value; + } + + // Compare source typeMap with LOCAL_TYPE_MAP + const localEntries = Object.entries(LOCAL_TYPE_MAP); + const sourceEntries = Object.entries(parsedSourceTypeMap); + + expect(sourceEntries.length).toBe(localEntries.length); + + for (const [key, value] of localEntries) { + expect(parsedSourceTypeMap).toHaveProperty(key, value); + } + }); +}); diff --git a/packages/cli/test/iac-commands.test.ts b/packages/cli/test/iac-commands.test.ts index 6ca0e9e..1209fd9 100644 --- a/packages/cli/test/iac-commands.test.ts +++ b/packages/cli/test/iac-commands.test.ts @@ -9,141 +9,447 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from "node:fs"; import os from "node:os"; import { join } from "node:path"; +// Import real functions +import { runIacAnalyze } from "../src/commands/iac/analyze"; +import { runIacExport } from "../src/commands/iac/export"; +import { runIacImport } from "../src/commands/iac/import"; +import { runMigrateFromConvex } from "../src/commands/migrate/from-convex"; + const tempDir = os.tmpdir(); +// Helper to capture console output +async function captureConsole(fn: () => Promise): Promise { + const originalLog = console.log; + const originalError = console.error; + const output: string[] = []; + console.log = (...args: any[]) => { + output.push(args.join(" ")); + }; + console.error = (...args: any[]) => { + output.push(args.join(" ")); + }; + try { + await fn(); + return output.join("\n"); + } finally { + console.log = originalLog; + console.error = originalError; + } +} + describe("runIacAnalyze", () => { + const testProjectRoot = join(tempDir, "iac-analyze-test"); + + beforeEach(() => { + mkdirSync(join(testProjectRoot, "betterbase", "queries"), { recursive: true }); + mkdirSync(join(testProjectRoot, "betterbase", "mutations"), { recursive: true }); + mkdirSync(join(testProjectRoot, "betterbase", "actions"), { recursive: true }); + }); + + afterEach(() => { + rmSync(testProjectRoot, { recursive: true, force: true }); + }); + it("should analyze queries and return results", async () => { - const mockResults = [ - { - path: "betterbase/queries/users.ts", - complexity: "high" as const, - issues: ["Unbounded results - no .take() limit"], - suggestions: ["Add .take(n) to limit results"], - }, - ]; - expect(mockResults.length).toBe(1); - expect(mockResults[0].complexity).toBe("high"); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "sample.ts"), + ` + export const getSample = query({ + run: async (ctx) => ctx.db.query("sample").take(5).collect(), + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot, { output: "json" })); + const jsonMatch = output.match(/\[.*\]/s); + expect(jsonMatch).not.toBeNull(); + const results = JSON.parse(jsonMatch![0]); + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBe(1); + expect(results[0].complexity).toBe("low"); + expect(results[0].issues).toEqual([]); }); it("should detect N+1 query patterns", async () => { - const analysis = { - content: "Promise.all(users.map(u => ctx.db.get(u.id)))", - hasNplus1: true, - }; - expect(analysis.hasNplus1).toBe(true); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "nplus1.ts"), + ` + export const getNested = query({ + run: async (ctx) => { + const users = ctx.db.query("users").take(100).collect(); + return Promise.all(users.map(async (u) => { + return ctx.db.query("posts").filter({ userId: u.id }).take(5).collect(); + })); + }, + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot)); + expect(output).toContain("N+1"); + // Should be medium due to N+1 pattern; not high because bounded + expect(output).toContain("medium"); }); it("should detect missing index usage", async () => { - const analysis = { - usesFilter: true, - hasIndex: false, - needsIndex: true, - }; - expect(analysis.needsIndex).toBe(true); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "filterNoIndex.ts"), + ` + export const getFiltered = query({ + run: async (ctx) => { + return ctx.db.query("items").filter({ status: "active" }).take(10).collect(); + }, + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot)); + expect(output).toContain("index"); + expect(output).toContain("medium"); }); it("should output results in json format", async () => { - const results = [{ path: "test.ts", complexity: "low" as const, issues: [], suggestions: [] }]; - const json = JSON.stringify(results, null, 2); - expect(json).toContain("test.ts"); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "jsonTest.ts"), + ` + export const getData = query({ + run: async (ctx) => ctx.db.query("data").take(1).collect(), + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot, { output: "json" })); + const jsonMatch = output.match(/\[.*\]/s); + expect(jsonMatch).not.toBeNull(); + const parsed = JSON.parse(jsonMatch![0]); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0]).toHaveProperty("path"); + expect(parsed[0]).toHaveProperty("complexity"); + expect(parsed[0]).toHaveProperty("issues"); + expect(parsed[0]).toHaveProperty("suggestions"); }); - it("should calculate complexity correctly", () => { - const testCases = [ - { content: "ctx.db.query('users').collect()", expected: "high" }, - { content: "ctx.db.query('users').filter({ active: true })", expected: "medium" }, - { content: "ctx.db.query('users').take(10)", expected: "low" }, - ]; - expect(testCases[0].expected).toBe("high"); - expect(testCases[1].expected).toBe("medium"); - expect(testCases[2].expected).toBe("low"); + it("should calculate complexity correctly", async () => { + // Low: bounded, indexed + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "low.ts"), + ` + export const low = query({ + run: async (ctx) => ctx.db.query("t").filter({ x: 1 }).withIndex("idx").take(5).collect(), + }); + `, + ); + // Medium: filter without index (but bounded) + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "medium.ts"), + ` + export const medium = query({ + run: async (ctx) => ctx.db.query("t").filter({ x: 1 }).take(5).collect(), + }); + `, + ); + // High: unbounded + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "high.ts"), + ` + export const high = query({ + run: async (ctx) => ctx.db.query("t").collect(), + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot)); + // Summary should show 1 low, 1 medium, 1 high + expect(output).toContain("Total: 3 | High: 1 | Medium: 1 | Low: 1"); + }); + + it("should detect N+1 query patterns using for loops", async () => { + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "nplus1-loop.ts"), + ` + export const getWithLoop = query({ + run: async (ctx) => { + const users = ctx.db.query("users").take(50).collect(); + const results = []; + for (const u of users) { + results.push(ctx.db.query("posts").filter({ userId: u.id }).take(5).collect()); + } + return results; + }, + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot)); + expect(output).toContain("N+1"); + }); + + it("should detect manual join patterns", async () => { + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "join.ts"), + ` + export const joinQuery = query({ + run: async (ctx) => { + const sql = \`SELECT u.*, p.* FROM users u JOIN posts p ON u.id = p.userId\`; + return ctx.db.execute(sql); + }, + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot)); + expect(output).toContain("join"); + }); + + it("should handle multiple query files", async () => { + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "users.ts"), + ` + export const getUsers = query({ + run: async (ctx) => ctx.db.query("users").take(10).collect(), + }); + `, + ); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "posts.ts"), + ` + export const getPosts = query({ + run: async (ctx) => ctx.db.query("posts").take(5).collect(), + }); + `, + ); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "comments.ts"), + ` + export const getComments = query({ + run: async (ctx) => { + const posts = ctx.db.query("posts").take(10).collect(); + return Promise.all(posts.map(p => ctx.db.query("comments").filter({ postId: p.id }).take(3).collect())); + }, + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot, { output: "json" })); + const jsonMatch = output.match(/\[.*\]/s); + expect(jsonMatch).not.toBeNull(); + const results = JSON.parse(jsonMatch![0]); + expect(results).toHaveLength(3); + }); + + it("should throw when queries directory is missing", async () => { + rmSync(join(testProjectRoot, "betterbase", "queries"), { recursive: true, force: true }); + await expect(runIacAnalyze(testProjectRoot)).rejects.toThrow(); + }); + + it("should support nested betterbase directory structure", async () => { + mkdirSync(join(testProjectRoot, "betterbase", "queries", "admin"), { recursive: true }); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "admin", "dashboard.ts"), + ` + export const getDashboardData = query({ + run: async (ctx) => { + const users = ctx.db.query("users").take(50).collect(); + return Promise.all(users.map(u => + ctx.db.query("posts").filter({ userId: u.id }).take(10).collect() + )); + }, + }); + `, + ); + const output = await captureConsole(() => runIacAnalyze(testProjectRoot)); + expect(output).toContain("N+1"); + expect(output).toContain("medium"); }); }); describe("runIacExport", () => { + const testProjectRoot = join(tempDir, "iac-export-test"); + + beforeEach(() => { + mkdirSync(join(testProjectRoot, "betterbase"), { recursive: true }); + }); + + afterEach(() => { + rmSync(testProjectRoot, { recursive: true, force: true }); + }); + it("should handle json format export", async () => { - const options = { - format: "json" as const, - output: "./backup", - table: "users", - }; - expect(options.format).toBe("json"); - expect(options.output).toBe("./backup"); + const output = await captureConsole(() => + runIacExport(testProjectRoot, { format: "json", output: "./backup" }), + ); + expect(output).toContain("Format: json"); }); it("should handle sql format export", async () => { - const options = { - format: "sql" as const, - output: "./backup.sql", - table: "posts", - }; - expect(options.format).toBe("sql"); - expect(options.table).toBe("posts"); + const output = await captureConsole(() => + runIacExport(testProjectRoot, { format: "sql", output: "./backup.sql" }), + ); + expect(output).toContain("Format: sql"); }); - it("should use default format when not specified", () => { - const options = { output: "./backup", format: undefined }; - const format = options.format ?? "json"; - expect(format).toBe("json"); + it("should use default format when not specified", async () => { + const output = await captureConsole(() => + runIacExport(testProjectRoot, { output: "./backup" }), + ); + expect(output).toContain("Format: json"); + }); + + it("should handle output path correctly", async () => { + const output = await captureConsole(() => + runIacExport(testProjectRoot, { output: "/path/to/export" }), + ); + expect(output).toContain("/path/to/export"); + }); + + it("should handle table-specific export", async () => { + const output = await captureConsole(() => + runIacExport(testProjectRoot, { output: "./backup", table: "comments" }), + ); + expect(output).toContain("Table: comments"); + }); + + it("should accept absolute output paths", async () => { + const absPath = join(tempDir, "full-backup"); + const output = await captureConsole(() => + runIacExport(testProjectRoot, { format: "json", output: absPath }), + ); + expect(output).toContain(absPath); + }); + + it("should accept custom table names with special characters", async () => { + const output = await captureConsole(() => + runIacExport(testProjectRoot, { format: "json", output: "./backup", table: "user_profiles_v2" }), + ); + expect(output).toContain("user_profiles_v2"); }); - it("should handle output path correctly", () => { - const options = { output: "/path/to/export" }; - expect(options.output).toBe("/path/to/export"); + it("should log export initialization success", async () => { + const output = await captureConsole(() => + runIacExport(testProjectRoot, { format: "json", output: "./backup" }), + ); + expect(output).toContain("✓"); + expect(output).toContain("Export command initialized"); }); - it("should handle table-specific export", () => { - const options = { output: "./backup", table: "comments" }; - expect(options.table).toBe("comments"); + it("should handle nested output directories", async () => { + const nestedPath = join(testProjectRoot, "exports", "daily", "backup"); + const output = await captureConsole(() => + runIacExport(testProjectRoot, { format: "json", output: nestedPath }), + ); + expect(output).toContain(nestedPath); }); }); describe("runIacImport", () => { - it("should handle json input files", async () => { - const options = { - input: "data.json", - table: "users", - dryRun: false, - }; - expect(options.input.endsWith(".json")).toBe(true); + const testProjectRoot = join(tempDir, "iac-import-test"); + + beforeEach(() => { + mkdirSync(testProjectRoot, { recursive: true }); + mkdirSync(join(testProjectRoot, "betterbase"), { recursive: true }); }); - it("should handle sql input files", async () => { - const options = { - input: "data.sql", - table: "posts", - dryRun: false, - }; - expect(options.input.endsWith(".sql")).toBe(true); + afterEach(() => { + rmSync(testProjectRoot, { recursive: true, force: true }); }); - it("should handle dry-run mode", async () => { - const options = { - input: "data.json", - dryRun: true, - }; - expect(options.dryRun).toBe(true); + it("should detect json input files", async () => { + const importPath = join(testProjectRoot, "data.json"); + writeFileSync(importPath, JSON.stringify([{ id: 1, name: "test" }], null, 2)); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath, dryRun: true }), + ); + expect(output).toContain("JSON"); + expect(output).toContain(importPath); }); - it("should validate input file exists", async () => { - const inputFile = "/path/to/file.json"; - const isValid = inputFile.length > 0 && inputFile.endsWith(".json"); - expect(isValid).toBe(true); + it("should detect sql input files", async () => { + const importPath = join(testProjectRoot, "data.sql"); + writeFileSync(importPath, "INSERT INTO users VALUES (1, 'test');"); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath, dryRun: true }), + ); + expect(output).toContain("SQL"); + expect(output).toContain(importPath); }); - it("should use default dry-run value", () => { - const options = { input: "data.json", dryRun: undefined }; - const dryRun = options.dryRun ?? false; - expect(dryRun).toBe(false); + it("should respect dry-run flag", async () => { + const importPath = join(testProjectRoot, "data.json"); + writeFileSync(importPath, JSON.stringify([{ id: 1 }], null, 2)); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath, dryRun: true }), + ); + expect(output).toContain("Dry Run: Yes"); + }); + + it("should default dry-run to false", async () => { + const importPath = join(testProjectRoot, "data.json"); + writeFileSync(importPath, JSON.stringify([{ id: 1 }], null, 2)); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath }), + ); + expect(output).toContain("Dry Run: No"); + }); + + it("should error on missing input file", async () => { + // runIacImport uses statSync which throws ENOENT for missing files + await expect( + runIacImport(testProjectRoot, { input: join(testProjectRoot, "nonexistent.json") }), + ).rejects.toThrow("ENOENT"); + }); + + it("should handle table-specific imports", async () => { + const importPath = join(testProjectRoot, "data.json"); + writeFileSync(importPath, JSON.stringify([{ id: 1 }], null, 2)); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath, table: "users", dryRun: true }), + ); + expect(output).toContain("Table: users"); + }); + + it("should handle complex json data structures", async () => { + const importPath = join(testProjectRoot, "complex.json"); + const complexData = [ + { id: 1, name: "Alice", email: "alice@example.com", createdAt: "2024-01-01T00:00:00Z", metadata: { role: "admin", active: true } }, + { id: 2, name: "Bob", email: "bob@example.com" }, + ]; + writeFileSync(importPath, JSON.stringify(complexData, null, 2)); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath, dryRun: true }), + ); + expect(output).toContain("Import command initialized"); + }); + + it("should accept absolute input paths", async () => { + const absPath = join(tempDir, "external-data.json"); + writeFileSync(absPath, JSON.stringify([{ id: 1 }], null, 2)); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: absPath, dryRun: true }), + ); + expect(output).toContain(absPath); + }); + + it("should log import success after processing", async () => { + const importPath = join(testProjectRoot, "data.json"); + writeFileSync(importPath, JSON.stringify([{ id: 1 }], null, 2)); + const output = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath, dryRun: true }), + ); + expect(output).toContain("✓"); + expect(output).toContain("Import command initialized"); }); }); describe("runMigrateFromConvex", () => { + const testProjectRoot = join(tempDir, "convex-migration-test"); + + beforeEach(() => { + rmSync(testProjectRoot, { recursive: true, force: true }); + mkdirSync(testProjectRoot, { recursive: true }); + }); + + afterEach(() => { + rmSync(testProjectRoot, { recursive: true, force: true }); + }); + it("should convert Convex schema to BetterBase schema", async () => { + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); const convexSchema = ` import { defineSchema, defineTable } from 'convex/server'; import { v } from 'convex/values'; @@ -153,59 +459,285 @@ export default defineSchema({ name: v.string(), email: v.string(), }), + posts: defineTable({ + title: v.string(), + author: v.id("users"), + }), +}); +`; + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), convexSchema); + + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + + const schemaContent = readFileSync(join(testProjectRoot, "migrated", "betterbase", "schema.ts"), "utf-8"); + expect(schemaContent).toContain("@betterbase/core/iac"); + expect(schemaContent).toContain("defineSchema"); + }); + + it("should convert Convex queries to BetterBase queries", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + const queryFile = ` +import { query } from './_generated/server'; +import { v } from 'convex/values'; + +export const getUsers = query({ + args: { limit: v.optional(v.number()) }, + run: async (ctx, args) => { + return ctx.db.query('users').take(args.limit ?? 10).collect(); + }, +}); +`; + writeFileSync(join(testProjectRoot, "convex", "queries", "users.ts"), queryFile); + + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + + const converted = readFileSync(join(testProjectRoot, "migrated", "betterbase", "queries", "users.ts"), "utf-8"); + expect(converted).toContain('import { query, v } from "@betterbase/core/iac"'); + expect(converted).toContain("export const getUsers = query({"); + }); + + it("should convert Convex mutations to BetterBase mutations", async () => { + mkdirSync(join(testProjectRoot, "convex", "mutations"), { recursive: true }); + const mutationFile = ` +import { mutation } from './_generated/server'; +import { v } from 'convex/values'; + +export const createUser = mutation({ + args: { name: v.string() }, + run: async (ctx, { name }) => ctx.db.insert('users', { name }), +}); + +export const updateUser = mutation({ + args: { id: v.id('users'), name: v.string() }, + run: async (ctx, { id, name }) => ctx.db.patch(id, { name }), +}); +`; + writeFileSync(join(testProjectRoot, "convex", "mutations", "users.ts"), mutationFile); + + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + + const converted = readFileSync(join(testProjectRoot, "migrated", "betterbase", "mutations", "users.ts"), "utf-8"); + expect(converted).toContain('import { mutation, v } from "@betterbase/core/iac"'); + expect(converted).toContain("export const createUser = mutation({"); + }); + + it("should convert Convex actions to BetterBase actions", async () => { + mkdirSync(join(testProjectRoot, "convex", "actions"), { recursive: true }); + const actionFile = ` +import { action } from './_generated/server'; + +export const doSomething = action({ + run: async (ctx) => { + await ctx.runQuery(ctx.db, 'someQuery'); + }, }); `; - const hasConvexImport = convexSchema.includes("convex/server"); - expect(hasConvexImport).toBe(true); + writeFileSync(join(testProjectRoot, "convex", "actions", "tasks.ts"), actionFile); + + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + + const converted = readFileSync(join(testProjectRoot, "migrated", "betterbase", "actions", "tasks.ts"), "utf-8"); + expect(converted).toContain('import { action } from "@betterbase/core/iac"'); + expect(converted).toContain("export const doSomething = action({"); }); - it("should convert v.* validators", () => { - const validators = ["v.string()", "v.number()", "v.boolean()", "v.optional()"]; - expect(validators.length).toBe(4); + it("should create proper directory structure in output", async () => { + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), ` +export default defineSchema({}); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const basePath = join(testProjectRoot, "migrated", "betterbase"); + expect(statSync(join(basePath, "schema.ts")).isFile()).toBe(true); + expect(statSync(join(basePath, "queries")).isDirectory()).toBe(true); + expect(statSync(join(basePath, "mutations")).isDirectory()).toBe(true); + expect(statSync(join(basePath, "actions")).isDirectory()).toBe(true); + }); + + it("should replace Convex imports with BetterBase imports in functions", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + const queryFile = ` +import { query } from './_generated/server'; +import { v } from 'convex/values'; + +export const getById = query({ + args: { id: v.id('users') }, + run: async (ctx, { id }) => ctx.db.get(id), +}); +`; + writeFileSync(join(testProjectRoot, "convex", "queries", "getById.ts"), queryFile); + + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + + const converted = readFileSync(join(testProjectRoot, "migrated", "betterbase", "queries", "getById.ts"), "utf-8"); + expect(converted).toContain('@betterbase/core/iac'); + expect(converted).not.toContain("_generated/server"); + expect(converted).not.toContain("convex/values"); }); - it("should convert queries to BetterBase queries", () => { - const convexQuery = "export const getUser = query({"; - const converted = convexQuery.replace(/query\({/g, "query({"); - expect(converted).toContain("query"); + it("should handle schema with no tables", async () => { + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), ` +import { defineSchema } from 'convex/server'; +export default defineSchema({}); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + expect(readFileSync(join(testProjectRoot, "migrated", "betterbase", "schema.ts"), "utf-8")).toContain("defineSchema"); }); - it("should convert mutations to BetterBase mutations", () => { - const convexMutation = "export const createUser = mutation({"; - const converted = convexMutation.replace(/mutation\({/g, "mutation({"); - expect(converted).toContain("mutation"); + it("should generate migration report JSON file", async () => { + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), ` +export default defineSchema({}); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const report = JSON.parse( + readFileSync(join(testProjectRoot, "migrated", "betterbase", "convex-migration-report.json"), "utf-8") + ); + expect(report).toHaveProperty("schemaConverted"); + expect(report).toHaveProperty("counts"); + expect(report).toHaveProperty("issues"); + expect(report).toHaveProperty("files"); + expect(report.counts).toHaveProperty("queries"); + expect(report.counts).toHaveProperty("mutations"); + expect(report.counts).toHaveProperty("actions"); }); - it("should convert actions to BetterBase actions", () => { - const convexAction = "export const doSomething = action({"; - const converted = convexAction.replace(/action\({/g, "action({"); - expect(converted).toContain("action"); + it("should generate migration report markdown file", async () => { + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), ` +export default defineSchema({}); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const reportMd = readFileSync(join(testProjectRoot, "migrated", "betterbase", "convex-migration-report.md"), "utf-8"); + expect(reportMd).toContain("# Convex Migration Compatibility Report"); + expect(reportMd).toContain("## Conversion Summary"); + expect(reportMd).toContain("## Compatibility Findings"); + expect(reportMd).toContain("## File-Level Conversion Status"); }); - it("should create correct directory structure", () => { - const expectedDirs = ["betterbase/queries", "betterbase/mutations", "betterbase/actions"]; - expect(expectedDirs.length).toBe(3); - expect(expectedDirs[0]).toBe("betterbase/queries"); + it("should detect httpAction as blocker", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "queries", "blocker.ts"), ` +import { httpAction } from 'convex/server'; +export const api = httpAction({ handler: async () => {} }); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const report = JSON.parse( + readFileSync(join(testProjectRoot, "migrated", "betterbase", "convex-migration-report.json"), "utf-8") + ); + const httpIssue = report.issues.find((i: any) => i.pattern.includes("httpAction")); + expect(httpIssue).toBeDefined(); + expect(httpIssue.severity).toBe("blocker"); }); - it("should handle ctx.db.get syntax", () => { - const convexCode = 'await ctx.db.get("userId")'; - const converted = convexCode.replace( - /await ctx\.db\.get\(["'](.*?)["']\)/g, - 'await ctx.db.get("$1")', + it("should detect cronJobs as blocker", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "queries", "cron.ts"), ` +import { cronJobs } from 'convex/server'; +const cron = cronJobs([...]); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const report = JSON.parse( + readFileSync(join(testProjectRoot, "migrated", "betterbase", "convex-migration-report.json"), "utf-8") ); - expect(converted).toContain("ctx.db.get"); + const cronIssue = report.issues.find((i: any) => i.pattern.includes("cronJobs")); + expect(cronIssue).toBeDefined(); + expect(cronIssue.severity).toBe("blocker"); + }); + + it("should throw when input directory does not exist", async () => { + await expect( + runMigrateFromConvex({ + inputPath: join(testProjectRoot, "nonexistent"), + outputPath: join(testProjectRoot, "migrated"), + }), + ).rejects.toThrow("not a directory"); }); - it("should replace Convex imports with BetterBase imports", () => { - const convexImport = "import { query } from './_generated/server'"; - const betterbaseImport = 'import { query } from "@betterbase/core/iac"'; - expect(betterbaseImport).toContain("betterbase"); + it("should count converted files accurately in report", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + mkdirSync(join(testProjectRoot, "convex", "mutations"), { recursive: true }); + mkdirSync(join(testProjectRoot, "convex", "actions"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), ` +export default defineSchema({}); +`); + writeFileSync(join(testProjectRoot, "convex", "queries", "a.ts"), `export const a = query({ run: async () => {} });`); + writeFileSync(join(testProjectRoot, "convex", "mutations", "b.ts"), `export const b = mutation({ run: async () => {} });`); + writeFileSync(join(testProjectRoot, "convex", "actions", "c.ts"), `export const c = action({ run: async () => {} });`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const report = JSON.parse( + readFileSync(join(testProjectRoot, "migrated", "betterbase", "convex-migration-report.json"), "utf-8") + ); + expect(report.counts.queries).toBe(1); + expect(report.counts.mutations).toBe(1); + expect(report.counts.actions).toBe(1); + }); + + it("should convert v.string(), v.number(), v.boolean() validators", async () => { + const schema = ` +import { defineSchema, defineTable } from 'convex/server'; +import { v } from 'convex/values'; + +export default defineSchema({ + products: defineTable({ + name: v.string(), + price: v.number(), + inStock: v.boolean(), + }), +}); +`; + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), schema); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const converted = readFileSync(join(testProjectRoot, "migrated", "betterbase", "schema.ts"), "utf-8"); + expect(converted).toContain("v.string()"); + expect(converted).toContain("v.number()"); + expect(converted).toContain("v.boolean()"); }); }); describe("Integration Tests", () => { - const testProjectRoot = join(tempDir, "iac-test-project"); + const testProjectRoot = join(tempDir, "iac-integration-test"); beforeEach(() => { mkdirSync(join(testProjectRoot, "betterbase", "queries"), { recursive: true }); @@ -247,4 +779,204 @@ describe("Integration Tests", () => { const content = readFileSync(schemaPath, "utf-8"); expect(content).toContain("defineSchema"); }); + + it("should run full analyze-export-import workflow", async () => { + // Write queries + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "users.ts"), + ` + export const getUsers = query({ + run: async (ctx) => ctx.db.query("users").take(50).collect(), + }); + `, + ); + writeFileSync( + join(testProjectRoot, "betterbase", "queries", "posts.ts"), + ` + export const getPosts = query({ + run: async (ctx) => { + const users = ctx.db.query("users").take(100).collect(); + return Promise.all(users.map(u => ctx.db.query("posts").filter({ userId: u.id }).take(5).collect())); + }, + }); + `, + ); + + // Create schema + writeFileSync( + join(testProjectRoot, "betterbase", "schema.ts"), + ` + import { defineSchema, defineTable, v } from "@betterbase/core/iac"; + export default defineSchema({ + users: defineTable({ name: v.string(), email: v.string() }), + posts: defineTable({ title: v.string(), userId: v.id("users") }), + }); + `, + ); + + // Analyze using JSON output for reliable filename check + const analyzeOutput = await captureConsole(() => runIacAnalyze(testProjectRoot, { output: "json" })); + const analyzeResults = JSON.parse(analyzeOutput.match(/\[.*\]/s)![0]); + const postsResult = analyzeResults.find((r: any) => r.path.includes("posts.ts")); + expect(postsResult).toBeDefined(); + expect(postsResult.issues.some((i: string) => i.includes("N+1"))).toBe(true); + + // Export (just verify init) + const exportOutput = await captureConsole(() => + runIacExport(testProjectRoot, { format: "json", output: join(testProjectRoot, "backup") }), + ); + expect(exportOutput).toContain("Export command initialized"); + expect(exportOutput).toContain("✓"); + + // Prepare import file + const importPath = join(testProjectRoot, "backup", "users.json"); + mkdirSync(join(testProjectRoot, "backup"), { recursive: true }); + writeFileSync(importPath, JSON.stringify([{ name: "Test", email: "test@test.com" }], null, 2)); + + // Import dry-run + const importOutput = await captureConsole(() => + runIacImport(testProjectRoot, { input: importPath, dryRun: true }), + ); + expect(importOutput).toContain("Dry Run: Yes"); + expect(importOutput).toContain("✓"); + }); + + it("should handle Convex migration with blocker issues", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "queries", "blocker.ts"), ` +import { httpAction } from 'convex/server'; +export const api = httpAction({ handler: async () => {} }); +`); + const output = await captureConsole(() => + runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }), + ); + + expect(output).toContain("Blockers:"); + expect(output).toContain("Files requiring manual review"); + + const report = JSON.parse( + readFileSync(join(testProjectRoot, "migrated", "betterbase", "convex-migration-report.json"), "utf-8") + ); + expect(report.issues.length).toBeGreaterThan(0); + expect(report.files.some((f: any) => f.status === "manual-review")).toBe(true); + }); + + it("should complete full Convex migration with all file types", async () => { + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), ` +import { defineSchema, defineTable } from 'convex/server'; +import { v } from 'convex/values'; +export default defineSchema({ + users: defineTable({ name: v.string(), email: v.string() }), + posts: defineTable({ title: v.string(), author: v.id("users") }), +}); +`); + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "queries", "users.ts"), ` +import { query } from './_generated/server'; +import { v } from 'convex/values'; +export const getById = query({ args: { id: v.id('users') }, run: async (ctx, { id }) => ctx.db.get(id) }); +export const listAll = query({ run: async (ctx) => ctx.db.query('users').collect() }); +`); + mkdirSync(join(testProjectRoot, "convex", "mutations"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "mutations", "users.ts"), ` +import { mutation } from './_generated/server'; +import { v } from 'convex/values'; +export const create = mutation({ args: { name: v.string() }, run: async (ctx, { name }) => ctx.db.insert('users', { name }) }); +export const update = mutation({ args: { id: v.id('users'), name: v.string() }, run: async (ctx, { id, name }) => ctx.db.patch(id, { name }) }); +`); + mkdirSync(join(testProjectRoot, "convex", "actions"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "actions", "maintenance.ts"), ` +import { action } from './_generated/server'; +export const cleanup = action({ run: async (ctx) => { await ctx.runQuery(ctx.db, 'cleanupOldRecords'); } }); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + + const migratedBase = join(testProjectRoot, "migrated", "betterbase"); + expect(readFileSync(join(migratedBase, "schema.ts"), "utf-8")).toContain("@betterbase/core/iac"); + expect(readFileSync(join(migratedBase, "queries", "users.ts"), "utf-8")).toContain("query"); + expect(readFileSync(join(migratedBase, "mutations", "users.ts"), "utf-8")).toContain("mutation"); + expect(readFileSync(join(migratedBase, "actions", "maintenance.ts"), "utf-8")).toContain("action"); + + const report = JSON.parse(readFileSync(join(migratedBase, "convex-migration-report.json"), "utf-8")); + expect(report.schemaConverted).toBe(true); + // One file per kind: one queries file (users.ts with two functions), one mutations file, one actions file + expect(report.counts.queries).toBe(1); + expect(report.counts.mutations).toBe(1); + expect(report.counts.actions).toBe(1); + expect(report.files.length).toBe(3); + }); + + it("should convert edge cases: optional fields and arrays", async () => { + mkdirSync(join(testProjectRoot, "convex"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "schema.ts"), ` +import { defineSchema, defineTable } from 'convex/server'; +import { v } from 'convex/values'; +export default defineSchema({ + products: defineTable({ + name: v.string(), + tags: v.array(v.string()), + optionalField: v.optional(v.number()), + }), +}); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const schema = readFileSync(join(testProjectRoot, "migrated", "betterbase", "schema.ts"), "utf-8"); + expect(schema).toContain("v.array(v.string())"); + expect(schema).toContain("v.optional(v.number())"); + }); + + it("should preserve function logic during conversion", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + writeFileSync(join(testProjectRoot, "convex", "queries", "complexLogic.ts"), ` +import { query } from './_generated/server'; +import { v } from 'convex/values'; +export const calculateStats = query({ + args: { range: v.string() }, + run: async (ctx, { range }) => { + const data = await ctx.db.query('metrics').filter({ period: range }).collect(); + const total = data.reduce((sum, d) => sum + d.value, 0); + return { count: data.length, total }; + }, +}); +`); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const converted = readFileSync( + join(testProjectRoot, "migrated", "betterbase", "queries", "complexLogic.ts"), + "utf-8" + ); + expect(converted).toContain("reduce"); + expect(converted).toContain("data.length"); + expect(converted).toContain("total"); + }); + + it("should not modify original Convex source files", async () => { + mkdirSync(join(testProjectRoot, "convex", "queries"), { recursive: true }); + writeFileSync( + join(testProjectRoot, "convex", "queries", "original.ts"), + ` +import { query } from './_generated/server'; +export const f = query({ run: async () => {} }); +`, + ); + const original = readFileSync(join(testProjectRoot, "convex", "queries", "original.ts"), "utf-8"); + await runMigrateFromConvex({ + inputPath: join(testProjectRoot, "convex"), + outputPath: join(testProjectRoot, "migrated"), + }); + const unchanged = readFileSync(join(testProjectRoot, "convex", "queries", "original.ts"), "utf-8"); + expect(unchanged).toBe(original); + }); }); diff --git a/packages/cli/test/integration/branch-commands.test.ts b/packages/cli/test/integration/branch-commands.test.ts new file mode 100644 index 0000000..b7c50b7 --- /dev/null +++ b/packages/cli/test/integration/branch-commands.test.ts @@ -0,0 +1,675 @@ +/** + * Branch Commands - Integration Behavioral Tests + * + * Tests all branch command functions with mocked dependencies. + * Replaces the 17 stub tests from test/branch-commands.test.ts. + */ + +import { afterEach, describe, expect, it, mock } from "bun:test"; +import path from "node:path"; + +const configModulePath = path.resolve(__dirname, "../../src/utils/config.ts"); + +// ── Mutable test state ────────────────────────────────────────────────────── +let mockConfigResult: any = null; + +let mockCreateBranchResult: any = { + success: true, + branch: { + id: "branch-1", + name: "test-branch", + previewUrl: "https://test-branch.preview.betterbase.io", + status: "active", + databaseConnectionString: "postgres://...", + storageBucket: "test-branch-bucket", + createdAt: new Date(), + lastAccessedAt: new Date(), + }, +}; +let mockListBranchesResult: any = { + branches: [ + { + id: "branch-1", + name: "test-branch", + previewUrl: "https://test-branch.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }, + ], + total: 1, + hasMore: false, +}; +let mockGetBranchByNameResult: any = { + id: "branch-1", + name: "existing-branch", + previewUrl: "https://existing-branch.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), +}; +let mockDeleteBranchResult: any = { success: true }; +let mockSleepBranchResult: any = { success: true }; +let mockWakeBranchResult: any = { success: true }; + +// ── Spies ─────────────────────────────────────────────────────────────────── +const createBranchSpy = mock(async (opts: any) => { + return { + success: mockCreateBranchResult.success, + branch: mockCreateBranchResult.branch + ? { ...mockCreateBranchResult.branch, name: opts.name } + : undefined, + error: mockCreateBranchResult.error, + warnings: mockCreateBranchResult.warnings, + }; +}); + +const listBranchesSpy = mock(() => ({ + branches: [...mockListBranchesResult.branches], + total: mockListBranchesResult.total, + hasMore: mockListBranchesResult.hasMore, +})); + +const getBranchByNameSpy = mock((name: string) => { + if (!mockGetBranchByNameResult) return undefined; + return { ...mockGetBranchByNameResult, name }; +}); + +const deleteBranchSpy = mock(async (id: string) => ({ ...mockDeleteBranchResult })); +const sleepBranchSpy = mock(async (id: string) => ({ ...mockSleepBranchResult })); +const wakeBranchSpy = mock(async (id: string) => ({ ...mockWakeBranchResult })); + +// ── Module mocks ──────────────────────────────────────────────────────────── +mock.module("@betterbase/core/branching", () => ({ + createBranchManager: () => ({ + createBranch: createBranchSpy, + listBranches: listBranchesSpy, + getBranchByName: getBranchByNameSpy, + deleteBranch: deleteBranchSpy, + sleepBranch: sleepBranchSpy, + wakeBranch: wakeBranchSpy, + }), + clearAllBranches: () => {}, + getAllBranches: () => [], +})); + +mock.module(configModulePath, () => ({ + loadConfig: async () => mockConfigResult, + findConfigFile: async () => null, + readConfigFile: async () => null, +})); + +// ── Dynamically import the module under test ──────────────────────────────── +const { + runBranchCreateCommand, + runBranchListCommand, + runBranchDeleteCommand, + runBranchSleepCommand, + runBranchWakeCommand, + runBranchCommand, +} = await import("../../src/commands/branch"); + +// ── Helpers ───────────────────────────────────────────────────────────────── +function resetMocks() { + mockConfigResult = null; + mockCreateBranchResult = { + success: true, + branch: { + id: "branch-1", + name: "test-branch", + previewUrl: "https://test-branch.preview.betterbase.io", + status: "active", + databaseConnectionString: "postgres://...", + storageBucket: "test-branch-bucket", + createdAt: new Date(), + lastAccessedAt: new Date(), + }, + }; + mockListBranchesResult = { + branches: [ + { + id: "branch-1", + name: "test-branch", + previewUrl: "https://test-branch.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }, + ], + total: 1, + hasMore: false, + }; + mockGetBranchByNameResult = { + id: "branch-1", + name: "existing-branch", + previewUrl: "https://existing-branch.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + mockDeleteBranchResult = { success: true }; + mockSleepBranchResult = { success: true }; + mockWakeBranchResult = { success: true }; + + createBranchSpy.mockClear(); + listBranchesSpy.mockClear(); + getBranchByNameSpy.mockClear(); + deleteBranchSpy.mockClear(); + sleepBranchSpy.mockClear(); + wakeBranchSpy.mockClear(); +} + +const validConfig = { + project: { name: "test-project" }, + provider: { type: "sqlite", connectionString: "local.db" }, + storage: { provider: "s3", bucket: "test-bucket", region: "us-east-1" }, + webhooks: [], +}; + +const TEMP_PROJECT_ROOT = path.resolve(__dirname, "../../test-fixtures-fake-dir"); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runBranchCreateCommand +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runBranchCreateCommand", () => { + afterEach(resetMocks); + + it("throws when branch name is not provided", async () => { + await expect( + runBranchCreateCommand([], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required. Usage: bb branch create "); + }); + + it("throws when config file cannot be loaded", async () => { + mockConfigResult = null; + await expect( + runBranchCreateCommand(["my-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow( + "Could not load configuration from betterbase.config.ts. Make sure you're in a BetterBase project directory.", + ); + }); + + it("creates a branch successfully with a valid name and config", async () => { + mockConfigResult = validConfig; + mockCreateBranchResult = { + success: true, + branch: { + id: "branch-2", + name: "my-feature", + previewUrl: "https://my-feature.preview.betterbase.io", + status: "active", + databaseConnectionString: "postgres://preview/my-feature", + storageBucket: "my-feature-bucket", + createdAt: new Date(), + lastAccessedAt: new Date(), + }, + }; + + await runBranchCreateCommand(["my-feature"], TEMP_PROJECT_ROOT); + + expect(createBranchSpy).toHaveBeenCalledTimes(1); + const callArg = createBranchSpy.mock.calls[0][0]; + expect(callArg.name).toBe("my-feature"); + expect(callArg.sourceBranch).toBe("main"); + expect(callArg.copyDatabase).toBe(true); + expect(callArg.copyStorage).toBe(true); + }); + + it("throws when branch creation fails (success: false)", async () => { + mockConfigResult = validConfig; + mockCreateBranchResult = { + success: false, + error: "Branch limit exceeded", + }; + + await expect( + runBranchCreateCommand(["my-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Failed to create preview environment: Branch limit exceeded"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runBranchListCommand +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runBranchListCommand", () => { + afterEach(resetMocks); + + it("throws when config file cannot be loaded", async () => { + mockConfigResult = null; + await expect( + runBranchListCommand([], TEMP_PROJECT_ROOT), + ).rejects.toThrow( + "Could not load configuration from betterbase.config.ts. Make sure you're in a BetterBase project directory.", + ); + }); + + it("lists branches when config is valid and branches exist", async () => { + mockConfigResult = validConfig; + mockListBranchesResult = { + branches: [ + { + id: "b1", + name: "feature-a", + previewUrl: "https://feature-a.preview.betterbase.io", + status: "active", + createdAt: new Date("2026-01-15"), + lastAccessedAt: new Date("2026-04-20"), + }, + { + id: "b2", + name: "feature-b", + previewUrl: "https://feature-b.preview.betterbase.io", + status: "sleeping", + createdAt: new Date("2026-02-10"), + lastAccessedAt: new Date("2026-03-01"), + }, + ], + total: 2, + hasMore: false, + }; + + await runBranchListCommand([], TEMP_PROJECT_ROOT); + + expect(listBranchesSpy).toHaveBeenCalledTimes(1); + }); + + it("shows empty state message when no branches exist", async () => { + mockConfigResult = validConfig; + mockListBranchesResult = { + branches: [], + total: 0, + hasMore: false, + }; + + await runBranchListCommand([], TEMP_PROJECT_ROOT); + + expect(listBranchesSpy).toHaveBeenCalledTimes(1); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runBranchDeleteCommand +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runBranchDeleteCommand", () => { + afterEach(resetMocks); + + it("throws when branch name is not provided", async () => { + await expect( + runBranchDeleteCommand([], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required. Usage: bb branch delete "); + }); + + it("throws when config file cannot be loaded", async () => { + mockConfigResult = null; + await expect( + runBranchDeleteCommand(["my-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow( + "Could not load configuration from betterbase.config.ts. Make sure you're in a BetterBase project directory.", + ); + }); + + it("throws when branch name is not found", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = undefined; + + await expect( + runBranchDeleteCommand(["nonexistent-branch"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Preview environment 'nonexistent-branch' not found."); + }); + + it("deletes an existing branch successfully", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "branch-xyz", + name: "stale-feature", + previewUrl: "https://stale-feature.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchDeleteCommand(["stale-feature"], TEMP_PROJECT_ROOT); + + expect(getBranchByNameSpy).toHaveBeenCalledWith("stale-feature"); + expect(deleteBranchSpy).toHaveBeenCalledWith("branch-xyz"); + }); + + it("throws when delete operation fails", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "branch-xyz", + name: "stale-feature", + previewUrl: "https://stale-feature.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + mockDeleteBranchResult = { + success: false, + error: "Database cleanup failed", + }; + + await expect( + runBranchDeleteCommand(["stale-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Failed to delete preview environment: Database cleanup failed"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runBranchSleepCommand +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runBranchSleepCommand", () => { + afterEach(resetMocks); + + it("throws when branch name is not provided", async () => { + await expect( + runBranchSleepCommand([], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required. Usage: bb branch sleep "); + }); + + it("throws when config file cannot be loaded", async () => { + mockConfigResult = null; + await expect( + runBranchSleepCommand(["my-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow( + "Could not load configuration from betterbase.config.ts. Make sure you're in a BetterBase project directory.", + ); + }); + + it("throws when branch name is not found", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = undefined; + + await expect( + runBranchSleepCommand(["nonexistent"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Preview environment 'nonexistent' not found."); + }); + + it("puts a branch to sleep successfully", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "branch-idle", + name: "idle-feature", + previewUrl: "https://idle-feature.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchSleepCommand(["idle-feature"], TEMP_PROJECT_ROOT); + + expect(getBranchByNameSpy).toHaveBeenCalledWith("idle-feature"); + expect(sleepBranchSpy).toHaveBeenCalledWith("branch-idle"); + expect(sleepBranchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws when sleep operation fails", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "branch-idle", + name: "idle-feature", + previewUrl: "https://idle-feature.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + mockSleepBranchResult = { + success: false, + error: "Branch not in active state", + }; + + await expect( + runBranchSleepCommand(["idle-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Failed to sleep preview environment: Branch not in active state"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runBranchWakeCommand +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runBranchWakeCommand", () => { + afterEach(resetMocks); + + it("throws when branch name is not provided", async () => { + await expect( + runBranchWakeCommand([], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required. Usage: bb branch wake "); + }); + + it("throws when config file cannot be loaded", async () => { + mockConfigResult = null; + await expect( + runBranchWakeCommand(["my-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow( + "Could not load configuration from betterbase.config.ts. Make sure you're in a BetterBase project directory.", + ); + }); + + it("throws when branch name is not found", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = undefined; + + await expect( + runBranchWakeCommand(["nonexistent"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Preview environment 'nonexistent' not found."); + }); + + it("wakes a sleeping branch successfully", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "branch-dormant", + name: "dormant-feature", + previewUrl: "https://dormant-feature.preview.betterbase.io", + status: "sleeping", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchWakeCommand(["dormant-feature"], TEMP_PROJECT_ROOT); + + expect(getBranchByNameSpy).toHaveBeenCalledWith("dormant-feature"); + expect(wakeBranchSpy).toHaveBeenCalledWith("branch-dormant"); + expect(wakeBranchSpy).toHaveBeenCalledTimes(1); + }); + + it("throws when wake operation fails", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "branch-dormant", + name: "dormant-feature", + previewUrl: "https://dormant-feature.preview.betterbase.io", + status: "sleeping", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + mockWakeBranchResult = { + success: false, + error: "Wake quota exceeded", + }; + + await expect( + runBranchWakeCommand(["dormant-feature"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Failed to wake preview environment: Wake quota exceeded"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runBranchCommand — routing +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runBranchCommand routing", () => { + afterEach(resetMocks); + + describe('"create" subcommand', () => { + it("dispatches to runBranchCreateCommand", async () => { + mockConfigResult = validConfig; + + await runBranchCommand(["create", "my-branch"], TEMP_PROJECT_ROOT); + + expect(createBranchSpy).toHaveBeenCalledTimes(1); + }); + + it("re-throws errors from create (e.g. missing name)", async () => { + await expect( + runBranchCommand(["create"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required"); + }); + }); + + describe('"list" and "ls" subcommands', () => { + it('dispatches "list" to runBranchListCommand', async () => { + mockConfigResult = validConfig; + + await runBranchCommand(["list"], TEMP_PROJECT_ROOT); + + expect(listBranchesSpy).toHaveBeenCalledTimes(1); + }); + + it('dispatches "ls" alias to runBranchListCommand', async () => { + mockConfigResult = validConfig; + + await runBranchCommand(["ls"], TEMP_PROJECT_ROOT); + + expect(listBranchesSpy).toHaveBeenCalledTimes(1); + }); + + it("re-throws errors from list (e.g. missing config)", async () => { + mockConfigResult = null; + + await expect( + runBranchCommand(["list"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Could not load configuration"); + }); + }); + + describe('"delete", "remove", and "rm" subcommands', () => { + it('dispatches "delete" to runBranchDeleteCommand', async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "b-del", + name: "to-delete", + previewUrl: "https://to-delete.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchCommand(["delete", "to-delete"], TEMP_PROJECT_ROOT); + + expect(deleteBranchSpy).toHaveBeenCalledTimes(1); + }); + + it('dispatches "remove" alias to runBranchDeleteCommand', async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "b-rm", + name: "to-remove", + previewUrl: "https://to-remove.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchCommand(["remove", "to-remove"], TEMP_PROJECT_ROOT); + + expect(deleteBranchSpy).toHaveBeenCalledTimes(1); + }); + + it('dispatches "rm" alias to runBranchDeleteCommand', async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "b-rm2", + name: "to-rm", + previewUrl: "https://to-rm.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchCommand(["rm", "to-rm"], TEMP_PROJECT_ROOT); + + expect(deleteBranchSpy).toHaveBeenCalledTimes(1); + }); + + it("re-throws errors from delete (e.g. missing name)", async () => { + await expect( + runBranchCommand(["delete"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required"); + }); + }); + + describe('"sleep" subcommand', () => { + it("dispatches to runBranchSleepCommand", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "b-sleep", + name: "nap-time", + previewUrl: "https://nap-time.preview.betterbase.io", + status: "active", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchCommand(["sleep", "nap-time"], TEMP_PROJECT_ROOT); + + expect(sleepBranchSpy).toHaveBeenCalledTimes(1); + }); + + it("re-throws errors from sleep (e.g. missing name)", async () => { + await expect( + runBranchCommand(["sleep"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required"); + }); + }); + + describe('"wake" subcommand', () => { + it("dispatches to runBranchWakeCommand", async () => { + mockConfigResult = validConfig; + mockGetBranchByNameResult = { + id: "b-wake", + name: "rise-shine", + previewUrl: "https://rise-shine.preview.betterbase.io", + status: "sleeping", + createdAt: new Date(), + lastAccessedAt: new Date(), + }; + + await runBranchCommand(["wake", "rise-shine"], TEMP_PROJECT_ROOT); + + expect(wakeBranchSpy).toHaveBeenCalledTimes(1); + }); + + it("re-throws errors from wake (e.g. missing name)", async () => { + await expect( + runBranchCommand(["wake"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Branch name is required"); + }); + }); + + describe("no subcommand", () => { + it("shows help without throwing", async () => { + await runBranchCommand([], TEMP_PROJECT_ROOT); + + // Help is printed to stdout — verify no error thrown + }); + + it("shows help when args are undefined", async () => { + await runBranchCommand(undefined as any, TEMP_PROJECT_ROOT); + + // Help is printed to stdout — verify no error thrown + }); + }); + + describe("unknown subcommand", () => { + it("throws for unrecognized subcommand", async () => { + await expect( + runBranchCommand(["foobar"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Unknown branch command: foobar"); + }); + + it("throws for any random string", async () => { + await expect( + runBranchCommand(["xyzzy"], TEMP_PROJECT_ROOT), + ).rejects.toThrow("Unknown branch command: xyzzy"); + }); + }); +}); diff --git a/packages/cli/test/integration/cross-product-workflow.test.ts b/packages/cli/test/integration/cross-product-workflow.test.ts new file mode 100644 index 0000000..a1ca253 --- /dev/null +++ b/packages/cli/test/integration/cross-product-workflow.test.ts @@ -0,0 +1,97 @@ +/** + * Cross-Product Integration — End-to-End Pipeline Tests + * + * Phase 4 deliverable: verifies that core CLI commands work together: + * 1. migrate (schema → migration files + apply) + * 2. graphql generate (schema → GraphQL SDL + server) + * 3. ContextGenerator (schema + routes → .betterbase-context.json) + * + * This test validates the developer workflow in a realistic project layout. + */ + +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createTestProject } from "../fixtures/fixtures"; + +// ── Import real modules ───────────────────────────────────────────────────────── +const { runMigrateCommand } = await import("../../src/commands/migrate"); +const { ContextGenerator } = await import("../../src/utils/context-generator"); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeProject() { + const root = createTestProject({ + "package.json": JSON.stringify({ name: "test-cross-product" }), + "src/db/schema.ts": ` +import { sqliteTable, text, timestamp } from 'drizzle-orm/sqlite-core'; +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + `, + // Minimal drizzle config for migrate command + "drizzle.config.ts": ` +import { defineConfig } from 'drizzle-kit'; +export default defineConfig({ + schema: './src/db/schema.ts', + dialect: 'sqlite', + db: process.env.DB_PATH || './local.db', + out: './drizzle/migrations', +}); + `, + }).root; + // Isolate the SQLite file inside the project directory + process.env.DB_PATH = join(root, "local.db"); + return { + root, + cleanup: () => { + delete process.env.DB_PATH; + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +function captureConsole() { + const lines: string[] = []; + const logSpy = mock((...args: unknown[]) => lines.push(args.map(String).join(" "))); + const origLog = console.log; + console.log = logSpy as unknown as typeof console.log; + return { lines, restore: () => { console.log = origLog; } }; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("Cross-Product Integration Pipeline (real implementations)", () => { + afterEach(() => { + mock.restore(); + }); + + it("migrate generates migrations and GraphQL schema, then context builds on them", async () => { + const { root, cleanup } = makeProject(); + try { + // Step 1: runMigrateCommand generates migrations, applies them, and generates GraphQL + await runMigrateCommand({ projectRoot: root }); + + // Assertions for migrate output + expect(existsSync(join(root, "drizzle", "migrations", "0001_initial_up.sql"))).toBe(true); + // GraphQL generation performed by migrate + expect(existsSync(join(root, "src", "lib", "graphql", "schema.graphql"))).toBe(true); + expect(existsSync(join(root, "src", "routes", "graphql.ts"))).toBe(true); + + // Step 2: ContextGenerator reads schema and routes to produce context + const ctxGen = new ContextGenerator(); + const ctx = await ctxGen.generate(root); + + expect(ctx).toHaveProperty("tables"); + expect(ctx).toHaveProperty("routes"); + expect(ctx).toHaveProperty("graphql_schema"); + expect(ctx.graphql_endpoint).toBe("/api/graphql"); + expect(ctx.tables).toHaveProperty("users"); + expect(ctx.graphql_schema).toContain("type Query"); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/cli/test/integration/dev.test.ts b/packages/cli/test/integration/dev.test.ts new file mode 100644 index 0000000..30eef69 --- /dev/null +++ b/packages/cli/test/integration/dev.test.ts @@ -0,0 +1,298 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { createTestProject } from "../fixtures/fixtures"; + +// ── Mock state tracking ──────────────────────────────────────────────────────────── +let processManagerStarted = false; +let processManagerStopped = false; +let processManagerRestartCount = 0; +let watcherStarted = false; +let watcherStopped = false; +let queryLogEnabled = false; +let queryLogDisabled = false; +let contextGenerated = false; +let contextGenerateShouldThrow = false; +let contextGenerateError = ""; +let iacSyncCalled = false; +let iacGenerateCalled = false; + +// Track current test project for cleanup +let currentProject: ReturnType | null = null; + +// ── Mock state reset ───────────────────────────────────────────────────────────── +function resetMockState() { + processManagerStarted = false; + processManagerStopped = false; + processManagerRestartCount = 0; + watcherStarted = false; + watcherStopped = false; + queryLogEnabled = false; + queryLogDisabled = false; + contextGenerated = false; + contextGenerateShouldThrow = false; + contextGenerateError = ""; + iacSyncCalled = false; + iacGenerateCalled = false; +} + +// ── Env backup / restore ────────────────────────────────────────────────────────── +function saveEnv() { + const orig: Record = {}; + for (const key of ["QUERY_LOG", "NODE_ENV"]) { + orig[key] = process.env[key]; + } + return orig; +} + +function restoreEnv(orig: Record) { + for (const key of Object.keys(orig)) { + if (orig[key] !== undefined) { + process.env[key] = orig[key]; + } else { + delete process.env[key]; + } + } +} + +// ── Mocks ───────────────────────────────────────────────────────────────────────── +const processManagerPath = path.resolve(__dirname, "../../src/commands/dev/process-manager.ts"); +const watcherPath = path.resolve(__dirname, "../../src/commands/dev/watcher.ts"); +const queryLogPath = path.resolve(__dirname, "../../src/commands/dev/query-log.ts"); +const contextGenPath = path.resolve(__dirname, "../../src/utils/context-generator.ts"); +const iacGenPath = path.resolve(__dirname, "../../src/commands/iac/generate.ts"); +const iacSyncPath = path.resolve(__dirname, "../../src/commands/iac/sync.ts"); + +mock.module(processManagerPath, () => ({ + ProcessManager: class { + async start() { + processManagerStarted = true; + } + async stop() { + processManagerStopped = true; + } + async restart(_reason: string) { + processManagerRestartCount++; + } + }, +})); + +mock.module(watcherPath, () => ({ + DevWatcher: class { + on(_handler: unknown) { + return this; + } + start(_projectRoot: string) { + watcherStarted = true; + } + stop() { + watcherStopped = true; + } + }, +})); + +mock.module(queryLogPath, () => { + const queryLogMock = { + enable() { + queryLogEnabled = true; + }, + disable() { + queryLogDisabled = true; + }, + log(_entry: unknown) {}, + getEntries() { + return []; + }, + clear() {}, + }; + return { queryLog: queryLogMock, QueryLog: class {} }; +}); + +mock.module(contextGenPath, () => ({ + ContextGenerator: class { + async generate(_projectRoot: string) { + contextGenerated = true; + if (contextGenerateShouldThrow) { + throw new Error(contextGenerateError || "Simulated generation failure"); + } + return {}; + } + }, +})); + +mock.module(iacGenPath, () => ({ + runIacGenerate: async () => { + iacGenerateCalled = true; + }, +})); + +mock.module(iacSyncPath, () => ({ + runIacSync: async () => { + iacSyncCalled = true; + }, +})); + +// ── Dynamic import after mocks registered ───────────────────────────────────────── +const { runDevCommand } = await import("../../src/commands/dev"); + +// ═══════════════════════════════════════════════════════════════════════════════════ +describe("runDevCommand", () => { + let envBackup: ReturnType; + let baselineSIGINT: ((...args: any[]) => void)[]; + let baselineSIGTERM: ((...args: any[]) => void)[]; + +beforeEach(() => { + resetMockState(); + // Clean up any leftover project from previous test + if (currentProject) { + currentProject.cleanup(); + currentProject = null; + } + envBackup = saveEnv(); + delete process.env.QUERY_LOG; + process.env.NODE_ENV = "test"; + baselineSIGINT = process.listeners("SIGINT") as ((...args: any[]) => void)[]; + baselineSIGTERM = process.listeners("SIGTERM") as ((...args: any[]) => void)[]; +}); + +afterEach(() => { + // Remove only handlers added during the test + const currentSIGINT = process.listeners("SIGINT") as ((...args: any[]) => void)[]; + const currentSIGTERM = process.listeners("SIGTERM") as ((...args: any[]) => void)[]; + for (const fn of currentSIGINT) { + if (!baselineSIGINT.includes(fn)) { + process.removeListener("SIGINT", fn); + } + } + for (const fn of currentSIGTERM) { + if (!baselineSIGTERM.includes(fn)) { + process.removeListener("SIGTERM", fn); + } + } + if (currentProject) { + currentProject.cleanup(); + currentProject = null; + } + restoreEnv(envBackup); +}); + + // 1 ────────────────────────────────────────────────────────────────────────────── + it("creates cleanup function", async () => { + currentProject = createTestProject({ + "package.json": JSON.stringify({ name: "test" }), + "src/index.ts": "const app = {};\nexport default { port: 0, fetch: () => {} };\n", + "src/db/schema.ts": "export const users = {};\n", + }); + + const cleanup = await runDevCommand(currentProject.root); + + expect(cleanup).toBeFunction(); + expect(processManagerStarted).toBe(true); + expect(watcherStarted).toBe(true); + + await cleanup(); + }); + + // 2 ────────────────────────────────────────────────────────────────────────────── + it("detects betterbase/ directory", async () => { + currentProject = createTestProject({ + "package.json": JSON.stringify({ name: "test" }), + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + "betterbase/schema.ts": "export default {};\n", + }); + + const cleanup = await runDevCommand(currentProject.root); + + expect(iacSyncCalled).toBe(true); + expect(iacGenerateCalled).toBe(true); + + await cleanup(); + }); + + // 3 ────────────────────────────────────────────────────────────────────────────── + it("handles missing betterbase/ gracefully", async () => { + currentProject = createTestProject({ + "package.json": JSON.stringify({ name: "test" }), + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(currentProject.root); + + expect(iacSyncCalled).toBe(false); + expect(iacGenerateCalled).toBe(false); + expect(processManagerStarted).toBe(true); + + await cleanup(); + }); + + // 4 ────────────────────────────────────────────────────────────────────────────── + it("QUERY_LOG=true enables query log", async () => { + process.env.QUERY_LOG = "true"; + currentProject = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(currentProject.root); + + expect(queryLogEnabled).toBe(true); + expect(queryLogDisabled).toBe(false); + + await cleanup(); + + expect(queryLogDisabled).toBe(true); + }); + + // 5 ────────────────────────────────────────────────────────────────────────────── + it("QUERY_LOG=false disables query log", async () => { + process.env.QUERY_LOG = "false"; + currentProject = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(currentProject.root); + + expect(queryLogEnabled).toBe(false); + + await cleanup(); + }); + + // 6 ────────────────────────────────────────────────────────────────────────────── + it("validates project root exists", async () => { + const cleanup = await runDevCommand("/nonexistent/path/12345"); + + expect(cleanup).toBeFunction(); + + await cleanup(); + }); + + // 7 ────────────────────────────────────────────────────────────────────────────── + it("cleanup function can be called without error", async () => { + currentProject = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(currentProject.root); + + await expect(cleanup()).resolves.toBeUndefined(); + + expect(processManagerStopped).toBe(true); + expect(watcherStopped).toBe(true); + }); + + // 8 ────────────────────────────────────────────────────────────────────────────── + it("handles missing schema gracefully", async () => { + contextGenerateShouldThrow = true; + contextGenerateError = "Cannot find module: @betterbase/core"; + + currentProject = createTestProject({ + "src/index.ts": "export default { port: 0, fetch: () => {} };\n", + }); + + const cleanup = await runDevCommand(currentProject.root); + + expect(watcherStarted).toBe(true); + expect(processManagerStarted).toBe(true); + + await cleanup(); + }); +}); diff --git a/packages/cli/test/integration/function-commands.test.ts b/packages/cli/test/integration/function-commands.test.ts new file mode 100644 index 0000000..e197e56 --- /dev/null +++ b/packages/cli/test/integration/function-commands.test.ts @@ -0,0 +1,926 @@ +/** + * Function CLI Commands — Integration Behavioral Tests + * + * Tests all function command operations via runFunctionCommand routing + * with mocked @betterbase/core/functions and real filesystem operations. + * Replaces the 10 stub tests from test/function-commands.test.ts. + */ + +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createTestProject } from "../fixtures/fixtures"; + +// ── Mutable mock state ─────────────────────────────────────────────────────── + +let mockListFunctionsResult: Array<{ name: string; runtime: string }> = [ + { name: "hello", runtime: "cloudflare-workers" }, + { name: "auth-webhook", runtime: "vercel-edge" }, +]; +let mockIsFunctionBuiltResult: boolean = true; +let mockBundleFunctionResult: { + success: boolean; + outputPath?: string; + size?: number; + errors: string[]; +} = { success: true, outputPath: "/tmp/test.js", size: 1024, errors: [] }; +let mockReadFunctionConfigResult: { + name: string; + runtime: "cloudflare-workers" | "vercel-edge"; + env: string[]; +} | undefined = { name: "test", runtime: "cloudflare-workers", env: [] }; +let mockDeployToCloudflareResult: { + success: boolean; + url?: string; + logs: string[]; +} = { success: true, url: "https://test.example.workers.dev", logs: [] }; +let mockDeployToVercelResult: { + success: boolean; + url?: string; + logs: string[]; +} = { success: true, url: "https://test.vercel.app", logs: [] }; +let mockGetCloudflareLogsResult: { + success: boolean; + message?: string; + logs: string[]; +} = { success: true, message: "", logs: ["GET / 200 2ms", "POST /api 201 5ms"] }; +let mockGetVercelLogsResult: { + success: boolean; + message?: string; + logs: string[]; +} = { success: true, message: "", logs: ["200 / 2ms", "201 /api 5ms"] }; +let mockSyncEnvToCloudflareResult: { success: boolean; message: string } = { + success: true, + message: "Env vars synced", +}; + +// ── Spies ──────────────────────────────────────────────────────────────────── + +const listFunctionsSpy = mock(async () => [...mockListFunctionsResult]); +const isFunctionBuiltSpy = mock(async () => mockIsFunctionBuiltResult); +const readFunctionConfigSpy = mock(async () => + mockReadFunctionConfigResult ? { ...mockReadFunctionConfigResult } : undefined, +); +const bundleFunctionSpy = mock(async () => ({ ...mockBundleFunctionResult })); +const deployToCloudflareSpy = mock(async () => ({ ...mockDeployToCloudflareResult })); +const deployToVercelSpy = mock(async () => ({ ...mockDeployToVercelResult })); +const getCloudflareLogsSpy = mock(async () => ({ ...mockGetCloudflareLogsResult })); +const getVercelLogsSpy = mock(async () => ({ ...mockGetVercelLogsResult })); +const syncEnvToCloudflareSpy = mock(async () => ({ ...mockSyncEnvToCloudflareResult })); + +// ── Module mocks (must be before the dynamic import) ───────────────────────── + +mock.module("@betterbase/core/functions", () => ({ + listFunctions: listFunctionsSpy, + isFunctionBuilt: isFunctionBuiltSpy, + readFunctionConfig: readFunctionConfigSpy, + bundleFunction: bundleFunctionSpy, + deployToCloudflare: deployToCloudflareSpy, + deployToVercel: deployToVercelSpy, + getCloudflareLogs: getCloudflareLogsSpy, + getVercelLogs: getVercelLogsSpy, + syncEnvToCloudflare: syncEnvToCloudflareSpy, +})); + +// ── Dynamic import after mocks ─────────────────────────────────────────────── + +const { runFunctionCommand, stopAllFunctions } = await import("../../src/commands/function"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function resetAllMocks(): void { + mockListFunctionsResult = [ + { name: "hello", runtime: "cloudflare-workers" }, + { name: "auth-webhook", runtime: "vercel-edge" }, + ]; + mockIsFunctionBuiltResult = true; + mockBundleFunctionResult = { success: true, outputPath: "/tmp/test.js", size: 1024, errors: [] }; + mockReadFunctionConfigResult = { name: "test", runtime: "cloudflare-workers", env: [] }; + mockDeployToCloudflareResult = { + success: true, + url: "https://test.example.workers.dev", + logs: [], + }; + mockDeployToVercelResult = { success: true, url: "https://test.vercel.app", logs: [] }; + mockGetCloudflareLogsResult = { success: true, message: "", logs: ["GET / 200 2ms"] }; + mockGetVercelLogsResult = { success: true, message: "", logs: ["200 / 2ms"] }; + mockSyncEnvToCloudflareResult = { success: true, message: "Env vars synced" }; + + listFunctionsSpy.mockClear(); + isFunctionBuiltSpy.mockClear(); + readFunctionConfigSpy.mockClear(); + bundleFunctionSpy.mockClear(); + deployToCloudflareSpy.mockClear(); + deployToVercelSpy.mockClear(); + getCloudflareLogsSpy.mockClear(); + getVercelLogsSpy.mockClear(); + syncEnvToCloudflareSpy.mockClear(); + + listFunctionsSpy.mockImplementation(async () => [...mockListFunctionsResult]); + isFunctionBuiltSpy.mockImplementation(async () => mockIsFunctionBuiltResult); + readFunctionConfigSpy.mockImplementation(async () => + mockReadFunctionConfigResult ? { ...mockReadFunctionConfigResult } : undefined, + ); + bundleFunctionSpy.mockImplementation(async () => ({ ...mockBundleFunctionResult })); + deployToCloudflareSpy.mockImplementation(async () => ({ ...mockDeployToCloudflareResult })); + deployToVercelSpy.mockImplementation(async () => ({ ...mockDeployToVercelResult })); + getCloudflareLogsSpy.mockImplementation(async () => ({ ...mockGetCloudflareLogsResult })); + getVercelLogsSpy.mockImplementation(async () => ({ ...mockGetVercelLogsResult })); + syncEnvToCloudflareSpy.mockImplementation(async () => ({ ...mockSyncEnvToCloudflareResult })); +} + +function captureConsole() { + const lines: string[] = []; + const logSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const errorSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const warnSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const origLog = console.log; + const origError = console.error; + const origWarn = console.warn; + console.log = logSpy as unknown as typeof console.log; + console.error = errorSpy as unknown as typeof console.error; + console.warn = warnSpy as unknown as typeof console.warn; + return { + lines, + restore: () => { + console.log = origLog; + console.error = origError; + console.warn = origWarn; + }, + }; +} + +// ── Test state ─────────────────────────────────────────────────────────────── + +let projectRoot: string; +let captured: ReturnType; + +afterEach(() => { + captured?.restore(); + if (projectRoot) { + try { + rmSync(projectRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + resetAllMocks(); +}); + +afterAll(() => { + mock.restore(); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runFunctionCommand — "create" +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("runFunctionCommand create", () => { + it("creates a function directory with index.ts and config.ts", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["create", "my-custom-func"], projectRoot); + + const funcDir = join(projectRoot, "src", "functions", "my-custom-func"); + expect(existsSync(funcDir)).toBe(true); + expect(existsSync(join(funcDir, "index.ts"))).toBe(true); + expect(existsSync(join(funcDir, "config.ts"))).toBe(true); + + const indexContent = readFileSync(join(funcDir, "index.ts"), "utf-8"); + expect(indexContent).toContain("import { Hono } from 'hono'"); + expect(indexContent).toContain("const app = new Hono()"); + expect(indexContent).toContain("export default app"); + + const configContent = readFileSync(join(funcDir, "config.ts"), "utf-8"); + expect(configContent).toContain("name: 'my-custom-func'"); + expect(configContent).toContain("runtime: 'cloudflare-workers'"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Function created: src/functions/my-custom-func/"); + expect(output).toContain("Run with: bb function dev my-custom-func"); + }); + + it("creates a function with hyphens and underscores in name", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["create", "webhook_handler-v2"], projectRoot); + + const funcDir = join(projectRoot, "src", "functions", "webhook_handler-v2"); + expect(existsSync(funcDir)).toBe(true); + expect(existsSync(join(funcDir, "index.ts"))).toBe(true); + expect(existsSync(join(funcDir, "config.ts"))).toBe(true); + + const configContent = readFileSync(join(funcDir, "config.ts"), "utf-8"); + expect(configContent).toContain("name: 'webhook_handler-v2'"); + }); + + it("rejects names with special characters", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["create", "bad@name!"], projectRoot); + + const funcDir = join(projectRoot, "src", "functions", "bad@name!"); + expect(existsSync(funcDir)).toBe(false); + + const output = captured.lines.join("\n"); + expect(output).toContain("can only contain letters, numbers, underscores, and hyphens"); + }); + + it("rejects names with spaces", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["create", "my function"], projectRoot); + + const funcDir = join(projectRoot, "src", "functions", "my function"); + expect(existsSync(funcDir)).toBe(false); + + const output = captured.lines.join("\n"); + expect(output).toContain("can only contain letters, numbers, underscores, and hyphens"); + }); + + it("rejects names with dots (e.g. path traversal)", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["create", "../escape"], projectRoot); + + const funcDir = join(projectRoot, "src", "functions", "../escape"); + expect(existsSync(join(projectRoot, "src", "functions", "../escape"))).toBe(false); + + const output = captured.lines.join("\n"); + expect(output).toContain("can only contain letters, numbers, underscores, and hyphens"); + }); + + it("rejects missing function name", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["create"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Function name is required"); + expect(output).toContain("Usage: bb function create "); + }); + + it("rejects duplicate function name", async () => { + const project = createTestProject(); + projectRoot = project.root; + + // Create the function first + await runFunctionCommand(["create", "duplicate-func"], projectRoot); + const funcDir = join(projectRoot, "src", "functions", "duplicate-func"); + expect(existsSync(funcDir)).toBe(true); + + // Try creating the same name again + captured = captureConsole(); + await runFunctionCommand(["create", "duplicate-func"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain(`Function "duplicate-func" already exists`); + }); + + it("index.ts template contains POST handler", async () => { + const project = createTestProject(); + projectRoot = project.root; + + await runFunctionCommand(["create", "api-handler"], projectRoot); + + const indexContent = readFileSync( + join(projectRoot, "src", "functions", "api-handler", "index.ts"), + "utf-8", + ); + expect(indexContent).toContain("app.post("); + expect(indexContent).toContain("c.req.json()"); + expect(indexContent).toContain("received: body"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runFunctionCommand — "list" +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("runFunctionCommand list", () => { + it("lists functions with proper table format", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockListFunctionsResult = [ + { name: "hello", runtime: "cloudflare-workers" }, + { name: "auth-webhook", runtime: "vercel-edge" }, + ]; + mockIsFunctionBuiltResult = true; + + captured = captureConsole(); + await runFunctionCommand(["list"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Functions"); + expect(output).toContain("Name"); + expect(output).toContain("Runtime"); + expect(output).toContain("Status"); + expect(output).toContain("hello"); + expect(output).toContain("auth-webhook"); + expect(output).toContain("cloudflare-workers"); + expect(output).toContain("vercel-edge"); + expect(output).toContain("built"); + + expect(listFunctionsSpy).toHaveBeenCalledTimes(1); + }); + + it("shows 'not built' status for unbuilt functions", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockListFunctionsResult = [{ name: "wip-func", runtime: "cloudflare-workers" }]; + mockIsFunctionBuiltResult = false; + + captured = captureConsole(); + await runFunctionCommand(["list"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("wip-func"); + expect(output).toContain("not built"); + + expect(isFunctionBuiltSpy).toHaveBeenCalledTimes(1); + expect(isFunctionBuiltSpy).toHaveBeenCalledWith("wip-func", projectRoot); + }); + + it("shows message when no functions exist", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockListFunctionsResult = []; + + captured = captureConsole(); + await runFunctionCommand(["list"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("No functions found"); + expect(output).toContain("bb function create "); + expect(output).not.toContain("Name"); + expect(output).not.toContain("|---"); + }); + + it("calls isFunctionBuilt for each function", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockListFunctionsResult = [ + { name: "func-a", runtime: "cloudflare-workers" }, + { name: "func-b", runtime: "vercel-edge" }, + ]; + + captured = captureConsole(); + await runFunctionCommand(["list"], projectRoot); + + expect(isFunctionBuiltSpy).toHaveBeenCalledTimes(2); + expect(isFunctionBuiltSpy).toHaveBeenCalledWith("func-a", projectRoot); + expect(isFunctionBuiltSpy).toHaveBeenCalledWith("func-b", projectRoot); + }); + + it("handles mixed built/not-built status across functions", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockListFunctionsResult = [ + { name: "built-func", runtime: "cloudflare-workers" }, + { name: "unbuilt-func", runtime: "vercel-edge" }, + ]; + + // Return true first, then false + let callCount = 0; + isFunctionBuiltSpy.mockImplementation(async () => { + callCount++; + return callCount === 1; + }); + + captured = captureConsole(); + await runFunctionCommand(["list"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("built-func"); + expect(output).toContain("unbuilt-func"); + + // Both statuses appear + const lines = captured.lines; + const builtCount = lines.filter( + (l) => /\bbuilt\b/.test(l) && !/\bnot built\b/.test(l), + ).length; + const notBuiltCount = lines.filter((l) => /\bnot built\b/.test(l)).length; + expect(builtCount).toBeGreaterThanOrEqual(1); + expect(notBuiltCount).toBeGreaterThanOrEqual(1); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runFunctionCommand — "build" +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("runFunctionCommand build", () => { + it("builds a function successfully", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockBundleFunctionResult = { success: true, outputPath: "/tmp/built.js", size: 2048, errors: [] }; + + captured = captureConsole(); + await runFunctionCommand(["build", "test-func"], projectRoot); + + expect(bundleFunctionSpy).toHaveBeenCalledTimes(1); + expect(bundleFunctionSpy).toHaveBeenCalledWith("test-func", projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Building function"); + expect(output).toContain("test-func"); + expect(output).toContain("Build successful"); + expect(output).toContain("/tmp/built.js"); + expect(output).toContain("2.00 KB"); + }); + + it("reports errors when build fails", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockBundleFunctionResult = { + success: false, + errors: ["TypeError: Cannot read property 'x'", "SyntaxError: Unexpected token"], + }; + + captured = captureConsole(); + await runFunctionCommand(["build", "broken-func"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Build failed"); + expect(output).toContain("TypeError: Cannot read property 'x'"); + expect(output).toContain("SyntaxError: Unexpected token"); + expect(output).not.toContain("Build successful"); + }); + + it("rejects missing function name", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["build"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Function name is required"); + expect(output).toContain("Usage: bb function build "); + expect(bundleFunctionSpy).toHaveBeenCalledTimes(0); + }); + + it("handles bundle with multiple errors", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockBundleFunctionResult = { + success: false, + errors: ["Error 1: Missing import", "Error 2: Type mismatch", "Error 3: Circular dependency"], + }; + + captured = captureConsole(); + await runFunctionCommand(["build", "multi-error-func"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Build failed"); + expect(output).toContain("Error 1: Missing import"); + expect(output).toContain("Error 2: Type mismatch"); + expect(output).toContain("Error 3: Circular dependency"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runFunctionCommand — "deploy" +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("runFunctionCommand deploy", () => { + it("rejects missing function name", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["deploy"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Function name is required"); + expect(output).toContain("Usage: bb function deploy [--sync-env]"); + expect(bundleFunctionSpy).toHaveBeenCalledTimes(0); + }); + + it("errors when function directory does not exist", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "nonexistent-func"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain('Function "nonexistent-func" not found'); + expect(bundleFunctionSpy).toHaveBeenCalledTimes(0); + }); + + it("deploys to cloudflare-workers successfully", async () => { + const project = createTestProject({ + "src/functions/cf-func/index.ts": "export default {}", + "src/functions/cf-func/config.ts": + "export default { name: 'cf-func', runtime: 'cloudflare-workers', env: [] }", + }); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "cf-func", runtime: "cloudflare-workers", env: [] }; + mockBundleFunctionResult = { success: true, outputPath: "/tmp/cf-func.js", size: 512, errors: [] }; + mockDeployToCloudflareResult = { + success: true, + url: "https://cf-func.example.workers.dev", + logs: [], + }; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "cf-func"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Deployment complete"); + expect(output).toContain("cf-func"); + expect(output).toContain("cloudflare-workers"); + expect(output).toContain("https://cf-func.example.workers.dev"); + + expect(bundleFunctionSpy).toHaveBeenCalledTimes(1); + expect(deployToCloudflareSpy).toHaveBeenCalledTimes(1); + expect(deployToVercelSpy).toHaveBeenCalledTimes(0); + }); + + it("deploys to vercel-edge successfully", async () => { + const project = createTestProject({ + "src/functions/vc-func/index.ts": "export default {}", + "src/functions/vc-func/config.ts": + "export default { name: 'vc-func', runtime: 'vercel-edge', env: [] }", + }); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "vc-func", runtime: "vercel-edge", env: [] }; + mockBundleFunctionResult = { success: true, outputPath: "/tmp/vc-func.js", size: 768, errors: [] }; + mockDeployToVercelResult = { success: true, url: "https://vc-func.vercel.app", logs: [] }; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "vc-func"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Deployment complete"); + expect(output).toContain("vc-func"); + expect(output).toContain("vercel-edge"); + + expect(deployToVercelSpy).toHaveBeenCalledTimes(1); + expect(deployToCloudflareSpy).toHaveBeenCalledTimes(0); + }); + + it("reports build failure during deploy", async () => { + const project = createTestProject({ + "src/functions/broken-deploy/index.ts": "bad syntax", + }); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "broken-deploy", runtime: "cloudflare-workers", env: [] }; + mockBundleFunctionResult = { + success: false, + errors: ["Parse error: Unexpected identifier"], + }; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "broken-deploy"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Build failed"); + expect(output).toContain("Parse error: Unexpected identifier"); + expect(deployToCloudflareSpy).toHaveBeenCalledTimes(0); + }); + + it("handles deployment failure after successful build", async () => { + const project = createTestProject({ + "src/functions/fail-deploy/index.ts": "export default {}", + }); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "fail-deploy", runtime: "cloudflare-workers", env: [] }; + mockBundleFunctionResult = { success: true, outputPath: "/tmp/fail-deploy.js", size: 256, errors: [] }; + mockDeployToCloudflareResult = { + success: false, + logs: ["Error: Invalid script", "Error: Authentication failed"], + }; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "fail-deploy"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Deployment failed"); + expect(output).toContain("Error: Invalid script"); + expect(output).toContain("Error: Authentication failed"); + expect(output).not.toContain("Deployment complete"); + }); + + it("calls syncEnvToCloudflare when --sync-env flag is passed", async () => { + const project = createTestProject({ + "src/functions/sync-func/index.ts": "export default {}", + ".env": "SECRET_KEY=mysecretvalue\nAPI_URL=https://api.example.com\n", + }); + projectRoot = project.root; + + mockReadFunctionConfigResult = { + name: "sync-func", + runtime: "cloudflare-workers", + env: ["SECRET_KEY", "API_URL"], + }; + mockBundleFunctionResult = { success: true, outputPath: "/tmp/sync-func.js", size: 256, errors: [] }; + mockDeployToCloudflareResult = { + success: true, + url: "https://sync-func.example.workers.dev", + logs: [], + }; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "sync-func", "--sync-env"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Syncing"); + expect(output).toContain("environment variables"); + expect(output).toContain("SECRET_KEY"); + expect(output).toContain("(set)"); + expect(output).toContain("API_URL"); + expect(syncEnvToCloudflareSpy).toHaveBeenCalledTimes(1); + }); + +it("warns about missing env vars in .env when syncing", async () => { + const project = createTestProject({ + "src/functions/missing-env/index.ts": "export default {}", + ".env": "EXISTING_KEY=value\n", + }); + projectRoot = project.root; + + mockReadFunctionConfigResult = { + name: "missing-env", + runtime: "cloudflare-workers", + env: ["EXISTING_KEY", "MISSING_KEY"], + }; + mockBundleFunctionResult = { success: true, outputPath: "/tmp/missing-env.js", size: 256, errors: [] }; + mockDeployToCloudflareResult = { + success: true, + url: "https://missing-env.example.workers.dev", + logs: [], + }; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "missing-env", "--sync-env"], projectRoot); + // syncEnvToCloudflare should still be called even with missing vars (just warns) + expect(syncEnvToCloudflareSpy).toHaveBeenCalledTimes(1); + + const output = captured.lines.join("\n"); + expect(output).toContain("Warning: Missing env vars in .env: MISSING_KEY"); + expect(output).toContain("MISSING_KEY"); + expect(output).toContain("(not set)"); +}); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runFunctionCommand — "logs" +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("runFunctionCommand logs", () => { + it("rejects missing function name", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["logs"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Function name is required"); + expect(output).toContain("Usage: bb function logs "); + }); + + it("fetches and displays cloudflare worker logs", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "cf-logs", runtime: "cloudflare-workers", env: [] }; + mockGetCloudflareLogsResult = { + success: true, + logs: ["[2026-04-29] GET /api 200 2ms", "[2026-04-29] POST /api 201 5ms"], + }; + + captured = captureConsole(); + await runFunctionCommand(["logs", "cf-logs"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain('Fetching logs for "cf-logs"'); + expect(output).toContain("cloudflare-workers"); + expect(output).toContain("Logs"); + expect(output).toContain("[2026-04-29] GET /api 200 2ms"); + expect(output).toContain("[2026-04-29] POST /api 201 5ms"); + + expect(getCloudflareLogsSpy).toHaveBeenCalledTimes(1); + expect(getCloudflareLogsSpy).toHaveBeenCalledWith("cf-logs", projectRoot); + }); + + it("shows error when cloudflare logs fetch fails", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "cf-fail", runtime: "cloudflare-workers", env: [] }; + mockGetCloudflareLogsResult = { + success: false, + message: "Authentication failed", + logs: [], + }; + + captured = captureConsole(); + await runFunctionCommand(["logs", "cf-fail"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Authentication failed"); + expect(output).not.toContain("Logs:"); + }); + + it("fetches and displays vercel edge logs", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "vc-logs", runtime: "vercel-edge", env: [] }; + mockGetVercelLogsResult = { + success: true, + logs: ["200 / 3ms", "201 /api 7ms"], + }; + + captured = captureConsole(); + await runFunctionCommand(["logs", "vc-logs"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain('Fetching logs for "vc-logs"'); + expect(output).toContain("vercel-edge"); + expect(output).toContain("200 / 3ms"); + expect(output).toContain("201 /api 7ms"); + + expect(getVercelLogsSpy).toHaveBeenCalledTimes(1); + }); + + it("shows error when vercel logs fetch fails", async () => { + const project = createTestProject(); + projectRoot = project.root; + + mockReadFunctionConfigResult = { name: "vc-fail", runtime: "vercel-edge", env: [] }; + mockGetVercelLogsResult = { + success: false, + message: "Rate limit exceeded", + logs: [], + }; + + captured = captureConsole(); + await runFunctionCommand(["logs", "vc-fail"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Rate limit exceeded"); + expect(output).not.toContain("Logs:"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runFunctionCommand — routing +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("runFunctionCommand routing", () => { + it('routes "create" to function creation', async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["create", "route-test"], projectRoot); + + const funcDir = join(projectRoot, "src", "functions", "route-test"); + expect(existsSync(funcDir)).toBe(true); + expect(existsSync(join(funcDir, "index.ts"))).toBe(true); + }); + + it('routes "list" to function listing', async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["list"], projectRoot); + + expect(listFunctionsSpy).toHaveBeenCalledTimes(1); + const output = captured.lines.join("\n"); + expect(output).toContain("Functions"); + }); + + it('routes "build" to function building', async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["build", "bld-func"], projectRoot); + + expect(bundleFunctionSpy).toHaveBeenCalledTimes(1); + expect(bundleFunctionSpy).toHaveBeenCalledWith("bld-func", projectRoot); + }); + + it('routes "deploy" to function deployment', async () => { + const project = createTestProject({ + "src/functions/dep-func/index.ts": "export default {}", + }); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "dep-func"], projectRoot); + + expect(bundleFunctionSpy).toHaveBeenCalledTimes(1); + expect(deployToCloudflareSpy).toHaveBeenCalledTimes(1); + }); + + it('routes "logs" to function logs', async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["logs", "log-func"], projectRoot); + + expect(getCloudflareLogsSpy).toHaveBeenCalledTimes(1); + }); + + it("shows help for unknown action", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["unknown-action"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Unknown function action: unknown-action"); + expect(output).toContain("Available commands:"); + expect(output).toContain("bb function create "); + expect(output).toContain("bb function dev "); + expect(output).toContain("bb function build "); + expect(output).toContain("bb function list"); + expect(output).toContain("bb function logs "); + expect(output).toContain("bb function deploy "); + }); + + it("shows help when no action is provided (empty args)", async () => { + const project = createTestProject(); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand([], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Unknown function action: undefined"); + expect(output).toContain("Available commands:"); + }); + + it("handles deploy with --sync-env flag via routing", async () => { + const project = createTestProject({ + "src/functions/route-sync/index.ts": "export default {}", + }); + projectRoot = project.root; + + captured = captureConsole(); + await runFunctionCommand(["deploy", "route-sync", "--sync-env"], projectRoot); + + expect(bundleFunctionSpy).toHaveBeenCalledTimes(1); + expect(deployToCloudflareSpy).toHaveBeenCalledTimes(1); + // --sync-env calls syncEnvToCloudflare only if config.env has entries + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// stopAllFunctions +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("stopAllFunctions", () => { + it("completes without error when no functions are running", async () => { + captured = captureConsole(); + await stopAllFunctions(); + // Should not throw, should not log anything + expect(captured.lines).toHaveLength(0); + }); + + it("does not throw on subsequent calls", async () => { + await stopAllFunctions(); + await stopAllFunctions(); + await stopAllFunctions(); + // No errors after multiple calls + }); +}); diff --git a/packages/cli/test/integration/iac-workflow.test.ts b/packages/cli/test/integration/iac-workflow.test.ts new file mode 100644 index 0000000..bab99f7 --- /dev/null +++ b/packages/cli/test/integration/iac-workflow.test.ts @@ -0,0 +1,129 @@ +/** + * IAC Workflow — Full Pipeline Integration Tests + * + * Phase 4 deliverable: tests that exercise the complete IaC workflow: + * iac sync → iac generate → iac analyze + * + * Verifies file outputs, ordering guarantees, and end-to-end behavior. + */ + +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createTestProject } from "../fixtures/fixtures"; + +import { runIacSync } from "../../src/commands/iac/sync"; +import { runIacGenerate } from "../../src/commands/iac/generate"; +import { runIacAnalyze } from "../../src/commands/iac/analyze"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeProject() { + const proj = createTestProject({ + "package.json": JSON.stringify({ name: "test-iac-workflow" }), + // Real BetterBase IaC schema that sync expects + "betterbase/schema.ts": ` +import { defineSchema, defineTable, text, timestamp } from "@betterbase/core"; + +export default defineSchema({ + user: defineTable({ + id: text("id").primaryKey(), + email: text("email").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }), +}); + `, + }); + + // Link @betterbase/core from the workspace into the temp project + const nodeModulesPath = join(proj.root, "node_modules", "@betterbase"); + mkdirSync(nodeModulesPath, { recursive: true }); + const coreTarget = join(__dirname, "../../../core"); + const coreLink = join(nodeModulesPath, "core"); + // Verify coreTarget exists before creating symlink + if (!existsSync(coreTarget) || !statSync(coreTarget).isDirectory()) { + throw new Error(`Core target directory not found at ${coreTarget}`); + } + if (!existsSync(coreLink)) { + symlinkSync(coreTarget, coreLink); + } + + return proj; +} + +function captureConsole() { + const lines: string[] = []; + const logSpy = mock((...args: unknown[]) => lines.push(args.map(String).join(" "))); + const origLog = console.log; + console.log = logSpy as unknown as typeof console.log; + return { lines, restore: () => { console.log = origLog; } }; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("IAC Workflow Pipeline (real implementations)", () => { + afterEach(() => { + mock.restore(); + }); + + it("iac sync generates schema.json and drizzle migrations", async () => { + const proj = makeProject(); + try { + await runIacSync(proj.root); + expect(existsSync(join(proj.root, "betterbase/_generated/schema.json"))).toBe(true); + expect(existsSync(join(proj.root, "drizzle/migrations"))).toBe(true); + } finally { + proj.cleanup(); + } + }); + + it("iac generate creates api.d.ts", async () => { + const proj = makeProject(); + try { + await runIacGenerate(proj.root); + expect(existsSync(join(proj.root, "betterbase/_generated/api.d.ts"))).toBe(true); + } finally { + proj.cleanup(); + } + }); + + it("iac analyze scans queries and outputs analysis", async () => { + const proj = makeProject(); + try { + mkdirSync(join(proj.root, "betterbase", "queries"), { recursive: true }); + writeFileSync( + join(proj.root, "betterbase", "queries", "getUsers.ts"), + `import { query } from "@betterbase/core"; +export const getUsers = query((c) => c.table("user").select());`, + ); + const captured = captureConsole(); + try { + await runIacAnalyze(proj.root); + expect(captured.lines.length).toBeGreaterThan(0); + } finally { + captured.restore(); + } + } finally { + proj.cleanup(); + } + }); + + it("full pipeline: sync → generate → analyze", async () => { + const proj = makeProject(); + try { + await runIacSync(proj.root); + await runIacGenerate(proj.root); + expect(existsSync(join(proj.root, "betterbase/_generated/api.d.ts"))).toBe(true); + + const captured = captureConsole(); + try { + await runIacAnalyze(proj.root); + expect(captured.lines.length).toBeGreaterThan(0); + } finally { + captured.restore(); + } + } finally { + proj.cleanup(); + } + }); +}); diff --git a/packages/cli/test/integration/init.test.ts b/packages/cli/test/integration/init.test.ts new file mode 100644 index 0000000..24f0b2a --- /dev/null +++ b/packages/cli/test/integration/init.test.ts @@ -0,0 +1,349 @@ +import { afterEach, beforeEach, describe, expect, it, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; + +import { type InitCommandOptions, runInitCommand } from "../../src/commands/init"; + +// --------------------------------------------------------------------------- +// Replicated schemas / helpers from init.ts (NOT exported — replicated here +// identically so we can validate the same logic the real init command uses) +// --------------------------------------------------------------------------- + +const projectNameSchema = z + .string() + .trim() + .min(1) + .regex( + /^[a-zA-Z0-9-_]+$/, + "Project name can only contain letters, numbers, hyphens, and underscores.", + ); + +const initOptionsSchema = z.object({ + projectName: projectNameSchema.optional(), + iac: z.boolean().optional(), +}); + +const providerTypeSchema = z.enum([ + "neon", + "turso", + "planetscale", + "supabase", + "postgres", + "managed", +]); + +function getDatabaseLabel(provider: string): string { + const labels: Record = { + neon: "Neon (serverless Postgres)", + turso: "Turso (edge SQLite)", + planetscale: "PlanetScale (MySQL-compatible)", + supabase: "Supabase (Postgres)", + postgres: "Raw Postgres", + managed: "Managed by BetterBase (coming soon)", + }; + return labels[provider] ?? "Unknown"; +} + +function getAuthDialect(provider: string): "sqlite" | "pg" | "mysql" { + if (provider === "turso") return "sqlite"; + if (provider === "planetscale") return "mysql"; + return "pg"; +} + +function containsTableDefinition( + content: string, + tableName: string, + importModule: string, + tableFn: string, +): boolean { + return ( + content.includes(importModule) && + content.includes(`export const ${tableName}`) && + content.includes(`${tableFn}(`) && + content.includes(".primaryKey()") + ); +} + +// --------------------------------------------------------------------------- +// projectNameSchema +// --------------------------------------------------------------------------- + +describe("projectNameSchema", () => { + test("accepts valid names (alphanumeric, hyphens, underscores)", () => { + expect(() => projectNameSchema.parse("my-project")).not.toThrow(); + expect(() => projectNameSchema.parse("MyApp")).not.toThrow(); + expect(() => projectNameSchema.parse("my_app")).not.toThrow(); + expect(() => projectNameSchema.parse("foo123")).not.toThrow(); + expect(() => projectNameSchema.parse("A")).not.toThrow(); + expect(() => projectNameSchema.parse("my-betterbase-app")).not.toThrow(); + }); + + test("rejects empty strings", () => { + expect(() => projectNameSchema.parse("")).toThrow(); + expect(() => projectNameSchema.parse(" ")).toThrow(); + }); + + test("rejects names with special characters (spaces, @, !, etc.)", () => { + expect(() => projectNameSchema.parse("my app")).toThrow(); + expect(() => projectNameSchema.parse("my@app")).toThrow(); + expect(() => projectNameSchema.parse("hello!")).toThrow(); + expect(() => projectNameSchema.parse("test/app")).toThrow(); + expect(() => projectNameSchema.parse("name.with.dots")).toThrow(); + }); + + test("trims whitespace before validation", () => { + expect(() => projectNameSchema.parse(" my-app ")).not.toThrow(); + expect(projectNameSchema.parse(" my-app ")).toBe("my-app"); + }); +}); + +// --------------------------------------------------------------------------- +// initOptionsSchema +// --------------------------------------------------------------------------- + +describe("initOptionsSchema", () => { + test("accepts empty object", () => { + expect(() => initOptionsSchema.parse({})).not.toThrow(); + }); + + test("accepts object with valid projectName", () => { + expect(() => + initOptionsSchema.parse({ projectName: "my-app" }), + ).not.toThrow(); + }); + + test("accepts object with iac flag", () => { + expect(() => initOptionsSchema.parse({ iac: true })).not.toThrow(); + expect(() => initOptionsSchema.parse({ iac: false })).not.toThrow(); + }); + + test("accepts object with both projectName and iac", () => { + const result = initOptionsSchema.parse({ + projectName: "test-project", + iac: false, + }); + expect(result.projectName).toBe("test-project"); + expect(result.iac).toBe(false); + }); + + test("rejects object with invalid projectName", () => { + expect(() => + initOptionsSchema.parse({ projectName: "bad name!" }), + ).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// providerTypeSchema +// --------------------------------------------------------------------------- + +describe("providerTypeSchema", () => { + test("accepts all valid provider types", () => { + const validProviders = [ + "neon", + "turso", + "planetscale", + "supabase", + "postgres", + "managed", + ] as const; + + for (const provider of validProviders) { + expect(() => providerTypeSchema.parse(provider)).not.toThrow(); + expect(providerTypeSchema.parse(provider)).toBe(provider); + } + }); + + test("rejects invalid provider types", () => { + expect(() => providerTypeSchema.parse("sqlite")).toThrow(); + expect(() => providerTypeSchema.parse("mysql")).toThrow(); + expect(() => providerTypeSchema.parse("mongodb")).toThrow(); + expect(() => providerTypeSchema.parse("")).toThrow(); + expect(() => providerTypeSchema.parse("NEON")).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// getDatabaseLabel +// --------------------------------------------------------------------------- + +describe("getDatabaseLabel", () => { + test("returns correct label for neon", () => { + expect(getDatabaseLabel("neon")).toBe("Neon (serverless Postgres)"); + }); + + test("returns correct label for turso", () => { + expect(getDatabaseLabel("turso")).toBe("Turso (edge SQLite)"); + }); + + test("returns correct label for planetscale", () => { + expect(getDatabaseLabel("planetscale")).toBe( + "PlanetScale (MySQL-compatible)", + ); + }); + + test("returns correct label for supabase", () => { + expect(getDatabaseLabel("supabase")).toBe("Supabase (Postgres)"); + }); + + test("returns correct label for postgres", () => { + expect(getDatabaseLabel("postgres")).toBe("Raw Postgres"); + }); + + test("returns correct label for managed", () => { + expect(getDatabaseLabel("managed")).toBe( + "Managed by BetterBase (coming soon)", + ); + }); + + test("every known provider has a distinct label", () => { + const providers = [ + "neon", + "turso", + "planetscale", + "supabase", + "postgres", + "managed", + ] as const; + const labels = providers.map((p) => getDatabaseLabel(p)); + expect(new Set(labels).size).toBe(providers.length); + }); +}); + +// --------------------------------------------------------------------------- +// getAuthDialect +// --------------------------------------------------------------------------- + +describe("getAuthDialect", () => { + test("returns sqlite for turso", () => { + expect(getAuthDialect("turso")).toBe("sqlite"); + }); + + test("returns mysql for planetscale", () => { + expect(getAuthDialect("planetscale")).toBe("mysql"); + }); + + test("returns pg for neon", () => { + expect(getAuthDialect("neon")).toBe("pg"); + }); + + test("returns pg for postgres", () => { + expect(getAuthDialect("postgres")).toBe("pg"); + }); + + test("returns pg for supabase", () => { + expect(getAuthDialect("supabase")).toBe("pg"); + }); + + test("returns pg for managed", () => { + expect(getAuthDialect("managed")).toBe("pg"); + }); +}); + +// --------------------------------------------------------------------------- +// InitCommandOptions type +// --------------------------------------------------------------------------- + +describe("InitCommandOptions", () => { + test("allows empty object", () => { + const opts: InitCommandOptions = {}; + expect(opts).toBeDefined(); + }); + + test("allows projectName string", () => { + const opts: InitCommandOptions = { projectName: "my-app" }; + expect(opts.projectName).toBe("my-app"); + }); + + test("allows iac boolean flag", () => { + const optsTrue: InitCommandOptions = { iac: true }; + const optsFalse: InitCommandOptions = { iac: false }; + expect(optsTrue.iac).toBe(true); + expect(optsFalse.iac).toBe(false); + }); + + test("validation rejects invalid projectName via initOptionsSchema", () => { + const result = initOptionsSchema.safeParse({ + projectName: "bad name with spaces!", + }); + expect(result.success).toBe(false); + }); + + test("validation passes with valid combined options", () => { + const result = initOptionsSchema.safeParse({ + projectName: "valid-name", + iac: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.projectName).toBe("valid-name"); + expect(result.data.iac).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// runInitCommand — importable and callable +// --------------------------------------------------------------------------- + +describe("runInitCommand (IaC integration)", () => { + it("copies full IaC template into new project directory", async () => { + const projectName = `bb-test-${randomUUID().slice(0, 8)}`; + // Create a temporary parent directory in OS temp dir + const parentDir = join(tmpdir(), `tmp-integration-parent-${randomUUID().slice(0, 8)}`); + await mkdir(parentDir, { recursive: true }); + const projectPath = join(parentDir, projectName); + + // Clean start + try { + await rm(projectPath, { recursive: true, force: true }); + } catch { + /* ignore */ + } + + // Switch to parent directory so that runInitCommand creates project there + const origCwd = process.cwd(); + process.chdir(parentDir); + try { + await runInitCommand({ projectName }); + + // Expected files from templates/iac plus generated .env/.gitignore + expect(existsSync(join(projectPath, "package.json"))).toBe(true); + expect(existsSync(join(projectPath, "tsconfig.json"))).toBe(true); + expect(existsSync(join(projectPath, ".env"))).toBe(true); + expect(existsSync(join(projectPath, ".env.example"))).toBe(true); + expect(existsSync(join(projectPath, ".gitignore"))).toBe(true); + expect(existsSync(join(projectPath, "betterbase.config.ts"))).toBe(true); + expect(existsSync(join(projectPath, "betterbase", "schema.ts"))).toBe(true); + expect(existsSync(join(projectPath, "betterbase", "queries", "todos.ts"))).toBe(true); + expect(existsSync(join(projectPath, "betterbase", "mutations", "todos.ts"))).toBe(true); + expect(existsSync(join(projectPath, "betterbase", "cron.ts"))).toBe(true); + expect(existsSync(join(projectPath, "betterbase", "actions", ".gitkeep"))).toBe(true); + expect(existsSync(join(projectPath, "src", "index.ts"))).toBe(true); + expect(existsSync(join(projectPath, "src", "modules", "README.md"))).toBe(true); + expect(existsSync(join(projectPath, "src", "modules", ".gitkeep"))).toBe(true); + + // Spot-check contents + const pkg = JSON.parse(readFileSync(join(projectPath, "package.json"), "utf-8")); + expect(pkg.name).toBe(projectName); + expect(pkg.scripts.dev).toContain("bun"); + + const bbConfig = readFileSync(join(projectPath, "betterbase.config.ts"), "utf-8"); + expect(bbConfig).toContain("defineConfig"); + + const bbSchema = readFileSync(join(projectPath, "betterbase", "schema.ts"), "utf-8"); + expect(bbSchema).toContain("defineSchema"); + + const env = readFileSync(join(projectPath, ".env"), "utf-8"); + expect(env).toContain("DATABASE_URL"); + } finally { + process.chdir(origCwd); + await rm(projectPath, { recursive: true, force: true }); + await rm(parentDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/test/integration/login-commands.test.ts b/packages/cli/test/integration/login-commands.test.ts new file mode 100644 index 0000000..3a12309 --- /dev/null +++ b/packages/cli/test/integration/login-commands.test.ts @@ -0,0 +1,317 @@ +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + runLoginCommand, + runApiKeyLogin, + runLogoutCommand, + getCredentials, + isAuthenticated, +} from "../../src/commands/login"; +import { + DEVICE_CODE_RESPONSE, + TOKEN_RESPONSE_PENDING, + TOKEN_RESPONSE_SUCCESS, + ADMIN_ME_RESPONSE, + ADMIN_LOGIN_RESPONSE, + ADMIN_LOGIN_ERROR, + mockFetch, +} from "../fixtures/fetch-mock"; + +// Use sandboxed temp directory for credentials +const tempHomeDir = mkdtempSync(join(tmpdir(), "bb-login-test-")); +const CREDENTIALS_FILE = join(tempHomeDir, ".betterbase", "credentials.json"); + +// Save the original BB_CREDENTIALS_DIR before overwriting +const originalBBCredentialsDir = process.env.BB_CREDENTIALS_DIR; + +// Set env var BEFORE importing the module +process.env.BB_CREDENTIALS_DIR = join(tempHomeDir, ".betterbase"); + +function cleanupCredentialsFile() { + try { + if (existsSync(CREDENTIALS_FILE)) { + rmSync(CREDENTIALS_FILE); + } + rmSync(tempHomeDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + // Restore original BB_CREDENTIALS_DIR + if (originalBBCredentialsDir === undefined) { + delete process.env.BB_CREDENTIALS_DIR; + } else { + process.env.BB_CREDENTIALS_DIR = originalBBCredentialsDir; + } +} + +function createValidCredentials() { + return { + token: "token_test123", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; +} + +function createExpiredCredentials() { + return { + token: "token_expired", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: "2020-01-01T00:00:00.000Z", + }; +} + +function setupCredentialsFile(creds: ReturnType) { + mkdirSync(join(tempHomeDir, ".betterbase"), { recursive: true }); + writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2)); + return () => cleanupCredentialsFile(); +} + +function mockProcessExit() { + const origExit = process.exit; + const exitMock = mock((code: number) => { + throw new Error(`exit:${code}`); + }); + process.exit = exitMock as unknown as (code?: number) => never; + return () => { + process.exit = origExit; + }; +} + +describe("runLoginCommand", () => { + beforeEach(() => { + process.env.BB_CREDENTIALS_DIR = join(tempHomeDir, ".betterbase"); + }); + afterEach(cleanupCredentialsFile); + afterAll(cleanupCredentialsFile); + + it("completes device code flow and saves credentials", async () => { + const origSetTimeout = globalThis.setTimeout; + (globalThis as Record).setTimeout = (fn: (...args: unknown[]) => void, _ms?: number, ...args: unknown[]) => { + return origSetTimeout(fn, 1, ...args) as unknown as ReturnType; + }; + + let tokenCallCount = 0; + const origFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const method = init?.method ?? "GET"; + + if (method === "POST" && url.includes("/device/code")) { + return new Response(JSON.stringify(DEVICE_CODE_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (method === "POST" && url.includes("/device/token")) { + tokenCallCount++; + if (tokenCallCount === 1) { + return new Response(JSON.stringify(TOKEN_RESPONSE_PENDING), { + status: 202, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify(TOKEN_RESPONSE_SUCCESS), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (method === "GET" && url.includes("/admin/auth/me")) { + // Verify Authorization header is present and Bearer token + const headers = new Headers(init?.headers as HeadersInit); + const authHeader = headers.get("authorization"); + expect(authHeader).toBeDefined(); + expect(authHeader).toMatch(/^Bearer\s+.+$/); + return new Response(JSON.stringify(ADMIN_ME_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ error: "unmocked" }), { + status: 404, + }); + }) as typeof globalThis.fetch; + + try { + await runLoginCommand({ serverUrl: "https://api.betterbase.io" }); + + const raw = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")); + expect(raw.token).toBe("access_token_xyz789"); + expect(raw.admin_email).toBe("admin@test.com"); + expect(raw.server_url).toBe("https://api.betterbase.io"); + } finally { + globalThis.fetch = origFetch; + globalThis.setTimeout = origSetTimeout; + } + }); + + it("handles network error and exits process", async () => { + const restoreExit = mockProcessExit(); + const origFetch = globalThis.fetch; + + globalThis.fetch = (async () => { + throw new Error("Failed to fetch: connect ECONNREFUSED"); + }) as unknown as typeof globalThis.fetch; + + try { + await runLoginCommand(); + expect.unreachable("should have thrown exit"); + } catch (e: unknown) { + expect((e as Error).message).toBe("exit:1"); + } finally { + globalThis.fetch = origFetch; + restoreExit(); + } + }); +}); + +describe("runApiKeyLogin", () => { + afterEach(cleanupCredentialsFile); + afterAll(cleanupCredentialsFile); + + it("logs in and saves credentials via POST /admin/auth/login", async () => { + const routes = [ + { + method: "POST", + url: "/admin/auth/login", + status: 200, + body: ADMIN_LOGIN_RESPONSE, + }, + ]; + const fmock = mockFetch(routes); + const origFetch = globalThis.fetch; + globalThis.fetch = fmock as typeof globalThis.fetch; + + try { + await runApiKeyLogin({ + serverUrl: "https://api.betterbase.io", + email: "admin@test.com", + password: "password123", + }); + + const raw = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")); + expect(raw.token).toBe("api_key_token_123"); + expect(raw.admin_email).toBe("admin@test.com"); + expect(raw.server_url).toBe("https://api.betterbase.io"); + } finally { + globalThis.fetch = origFetch; + } + }); + + it("handles invalid credentials and exits process", async () => { + const restoreExit = mockProcessExit(); + const routes = [ + { + method: "POST", + url: "/admin/auth/login", + status: 401, + body: ADMIN_LOGIN_ERROR, + }, + ]; + const fmock = mockFetch(routes); + const origFetch = globalThis.fetch; + globalThis.fetch = fmock as typeof globalThis.fetch; + + try { + await runApiKeyLogin({ + email: "admin@test.com", + password: "wrong-password", + }); + expect.unreachable("should have thrown exit"); + } catch (e: unknown) { + expect((e as Error).message).toBe("exit:1"); + } finally { + globalThis.fetch = origFetch; + restoreExit(); + } + }); +}); + +describe("runLogoutCommand", () => { + afterEach(cleanupCredentialsFile); + afterAll(cleanupCredentialsFile); + + it("clears credentials", async () => { + const cleanup = setupCredentialsFile(createValidCredentials()); + + try { + await runLogoutCommand(); + + const content = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")); + expect(content).toEqual({}); + } finally { + cleanup(); + } + }); +}); + +describe("getCredentials", () => { + afterEach(cleanupCredentialsFile); + afterAll(cleanupCredentialsFile); + + it("returns credentials when saved", async () => { + const cleanup = setupCredentialsFile(createValidCredentials()); + + try { + const creds = await getCredentials(); + expect(creds).not.toBeNull(); + expect(creds!.token).toMatch(/^token_/); + expect(creds!.admin_email).toBe("admin@test.com"); + expect(creds!.server_url).toBe("https://api.betterbase.io"); + } finally { + cleanup(); + } + }); + + it("returns null when no credentials file exists", async () => { + cleanupCredentialsFile(); + + const creds = await getCredentials(); + expect(creds).toBeNull(); + }); +}); + +describe("isAuthenticated", () => { + afterEach(cleanupCredentialsFile); + afterAll(cleanupCredentialsFile); + + it("returns true when credentials exist", async () => { + const cleanup = setupCredentialsFile(createValidCredentials()); + + try { + const authed = await isAuthenticated(); + expect(authed).toBe(true); + } finally { + cleanup(); + } + }); + + it("returns false when no credentials exist", async () => { + cleanupCredentialsFile(); + + const authed = await isAuthenticated(); + expect(authed).toBe(false); + }); + + it("returns true with expired credentials (no expiry validation)", async () => { + const cleanup = setupCredentialsFile(createExpiredCredentials()); + + try { + const authed = await isAuthenticated(); + expect(authed).toBe(true); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/cli/test/integration/rls-commands.test.ts b/packages/cli/test/integration/rls-commands.test.ts new file mode 100644 index 0000000..233a4fe --- /dev/null +++ b/packages/cli/test/integration/rls-commands.test.ts @@ -0,0 +1,430 @@ +/** + * RLS Commands — Integration Behavioral Tests + * + * Tests for cli/src/commands/rls.ts functions. + * All functions work with filesystem only — no network/DB needed. + * Replaces the 17 stub tests from test/rls-commands.test.ts. + */ + +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createTestProject } from "../fixtures/fixtures"; + +// ── Dynamically import module under test ───────────────────────────────────── + +const { runRlsCreate, runRlsList, runRlsDisable, runRlsCommand } = await import( + "../../src/commands/rls" +); + +// ── Console capture helper ─────────────────────────────────────────────────── + +function captureConsole() { + const lines: string[] = []; + const logSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const errorSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const warnSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const origLog = console.log; + const origError = console.error; + const origWarn = console.warn; + console.log = logSpy as unknown as typeof console.log; + console.error = errorSpy as unknown as typeof console.error; + console.warn = warnSpy as unknown as typeof console.warn; + return { + lines, + restore: () => { + console.log = origLog; + console.error = origError; + console.warn = origWarn; + }, + }; +} + +// ── State ──────────────────────────────────────────────────────────────────── + +let projectRoot: string | undefined; +let cleanup: (() => void) | undefined; +let captured: ReturnType | undefined; +let originalCwd: string; + +originalCwd = process.cwd(); + +afterEach(() => { + captured?.restore(); + captured = undefined; + if (cleanup) { + try { + cleanup(); + } catch { + /* ignore */ + } + cleanup = undefined; + } + projectRoot = undefined; + process.chdir(originalCwd); +}); + +afterAll(() => { + mock.restore(); +}); + +function setupTestProject(files?: Record) { + const proj = createTestProject(files); + projectRoot = proj.root; + cleanup = proj.cleanup; + process.chdir(proj.root); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// runRlsCreate +// ═══════════════════════════════════════════════════════════════════════════════ + +describe.serial("runRlsCreate", () => { + it("creates a .policy.ts file with correct template", async () => { + setupTestProject(); + captured = captureConsole(); + + await runRlsCreate("users"); + + const policyPath = join(projectRoot!, "src", "db", "policies", "users.policy.ts"); + expect(existsSync(policyPath)).toBe(true); + + const content = readFileSync(policyPath, "utf-8"); + expect(content).toContain("@betterbase/core/rls"); + expect(content).toContain("definePolicy('users'"); + expect(content).toContain("select: \"auth.uid() = user_id\""); + expect(content).toContain("insert: \"auth.uid() = user_id\""); + expect(content).toContain("update: \"auth.uid() = user_id\""); + expect(content).toContain("delete: \"auth.uid() = user_id\""); + + const output = captured.lines.join("\n"); + expect(output).toContain("Created policy file:"); + expect(output).toContain("users.policy.ts"); + expect(output).toContain("Edit this file to configure your RLS policy"); + expect(output).toContain("bb migrate"); + }); + + it("sanitizes table name (special chars → underscores)", async () => { + setupTestProject(); + captured = captureConsole(); + + await runRlsCreate("my-table@with!special#chars"); + + const policyPath = join(projectRoot!, "src", "db", "policies", "my_table_with_special_chars.policy.ts"); + expect(existsSync(policyPath)).toBe(true); + + const content = readFileSync(policyPath, "utf-8"); + expect(content).toContain("definePolicy('my_table_with_special_chars'"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Created policy file:"); + expect(output).toContain("my_table_with_special_chars.policy.ts"); + }); + + it("sanitizes table name with spaces", async () => { + setupTestProject(); + captured = captureConsole(); + + await runRlsCreate("user accounts"); + + const policyPath = join(projectRoot!, "src", "db", "policies", "user_accounts.policy.ts"); + expect(existsSync(policyPath)).toBe(true); + + const content = readFileSync(policyPath, "utf-8"); + expect(content).toContain("definePolicy('user_accounts'"); + }); + + it("warns on duplicate policy", async () => { + setupTestProject(); + // Create the policy file first + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, "users.policy.ts"), "// existing policy"); + + captured = captureConsole(); + + await runRlsCreate("users"); + + // File content should NOT be overwritten + const content = readFileSync(join(policiesDir, "users.policy.ts"), "utf-8"); + expect(content).toBe("// existing policy"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Policy file already exists"); + expect(output).toContain("users.policy.ts"); + expect(output).toContain("bb rls disable"); + // Should NOT show "Created policy file" + expect(output).not.toContain("Created policy file"); + }); + + it("throws on missing table name (empty string)", async () => { + setupTestProject(); + captured = captureConsole(); + + await expect(runRlsCreate("")).rejects.toThrow( + "Table name is required. Usage: bb rls create ", + ); + }); + + it("throws on missing table name (undefined)", async () => { + setupTestProject(); + captured = captureConsole(); + + await expect(runRlsCreate(undefined as unknown as string)).rejects.toThrow( + "Table name is required. Usage: bb rls create
", + ); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runRlsList +// ═══════════════════════════════════════════════════════════════════════════════ + +describe.serial("runRlsList", () => { + it("lists multiple policies", async () => { + setupTestProject(); + // Create multiple policy files + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, "users.policy.ts"), "// users policy"); + writeFileSync(join(policiesDir, "posts.policy.ts"), "// posts policy"); + writeFileSync(join(policiesDir, "comments.policy.ts"), "// comments policy"); + + captured = captureConsole(); + + await runRlsList(); + + const output = captured.lines.join("\n"); + expect(output).toContain("RLS Policies"); + expect(output).toContain("Table"); + expect(output).toContain("File"); + // Table names appear in the listing + expect(output).toContain("users"); + expect(output).toContain("posts"); + expect(output).toContain("comments"); + // File names appear + expect(output).toContain("users.policy.ts"); + expect(output).toContain("posts.policy.ts"); + expect(output).toContain("comments.policy.ts"); + }); + + it("displays correct count", async () => { + setupTestProject(); + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, "users.policy.ts"), "// users"); + writeFileSync(join(policiesDir, "posts.policy.ts"), "// posts"); + + captured = captureConsole(); + + await runRlsList(); + + const output = captured.lines.join("\n"); + expect(output).toContain("Total: 2 policy file(s)"); + }); + + it("handles empty/no policies directory", async () => { + setupTestProject(); + + captured = captureConsole(); + + await runRlsList(); + + const output = captured.lines.join("\n"); + expect(output).toContain("No RLS policies found"); + expect(output).toContain("bb rls create
"); + // Should NOT show table/header + expect(output).not.toContain("RLS Policies"); + }); + + it("handles existing but empty policies directory", async () => { + setupTestProject(); + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + + captured = captureConsole(); + + await runRlsList(); + + const output = captured.lines.join("\n"); + expect(output).toContain("No RLS policies found"); + }); + + it("ignores non-policy files in the directory", async () => { + setupTestProject(); + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, "README.md"), "# Policies"); + writeFileSync(join(policiesDir, "users.policy.ts"), "// users policy"); + writeFileSync(join(policiesDir, "helpers.ts"), "export const helper = 1;"); + + captured = captureConsole(); + + await runRlsList(); + + const output = captured.lines.join("\n"); + // Only the .policy.ts file should be counted + expect(output).toContain("Total: 1 policy file(s)"); + expect(output).toContain("users"); + expect(output).toContain("users.policy.ts"); + expect(output).not.toContain("README.md"); + expect(output).not.toContain("helpers.ts"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runRlsDisable +// ═══════════════════════════════════════════════════════════════════════════════ + +describe.serial("runRlsDisable", () => { + it("shows delete instructions when policy exists", async () => { + setupTestProject(); + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, "users.policy.ts"), "// users policy"); + + captured = captureConsole(); + + await runRlsDisable("users"); + + const output = captured.lines.join("\n"); + expect(output).toContain("This will remove ALL RLS policies"); + expect(output).toContain("users"); + expect(output).toContain("To disable RLS:"); + expect(output).toContain("Delete the policy file:"); + expect(output).toContain("users.policy.ts"); + expect(output).toContain("bb migrate"); + expect(output).toContain("ALTER TABLE users DISABLE ROW LEVEL SECURITY"); + expect(output).toContain("DROP POLICY"); + }); + + it("handles missing policy (no policy file found)", async () => { + setupTestProject(); + + captured = captureConsole(); + + await runRlsDisable("nonexistent_table"); + + const output = captured.lines.join("\n"); + expect(output).toContain("This will remove ALL RLS policies"); + expect(output).toContain("nonexistent_table"); + expect(output).toContain("No policy file found for"); + expect(output).toContain("ALTER TABLE nonexistent_table DISABLE ROW LEVEL SECURITY"); + // Should NOT show "Delete the policy file" since there's no file + expect(output).not.toContain("Delete the policy file:"); + expect(output).not.toContain("bb migrate"); + }); + + it("throws on missing table name (empty string)", async () => { + setupTestProject(); + captured = captureConsole(); + + await expect(runRlsDisable("")).rejects.toThrow( + "Table name is required. Usage: bb rls disable
", + ); + }); + + it("throws on missing table name (undefined)", async () => { + setupTestProject(); + captured = captureConsole(); + + await expect(runRlsDisable(undefined as unknown as string)).rejects.toThrow( + "Table name is required. Usage: bb rls disable
", + ); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runRlsCommand — routing +// ═══════════════════════════════════════════════════════════════════════════════ + +describe.serial("runRlsCommand", () => { + it("routes 'create' to runRlsCreate", async () => { + setupTestProject(); + captured = captureConsole(); + + await runRlsCommand(["create", "widgets"]); + + const policyPath = join(projectRoot!, "src", "db", "policies", "widgets.policy.ts"); + expect(existsSync(policyPath)).toBe(true); + + const content = readFileSync(policyPath, "utf-8"); + expect(content).toContain("definePolicy('widgets'"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Created policy file:"); + }); + + it("routes 'list' to runRlsList", async () => { + setupTestProject(); + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, "items.policy.ts"), "// items policy"); + + captured = captureConsole(); + + await runRlsCommand(["list"]); + + const output = captured.lines.join("\n"); + expect(output).toContain("RLS Policies"); + expect(output).toContain("items"); + }); + + it("routes 'disable' to runRlsDisable", async () => { + setupTestProject(); + const policiesDir = join(projectRoot!, "src", "db", "policies"); + mkdirSync(policiesDir, { recursive: true }); + writeFileSync(join(policiesDir, "widgets.policy.ts"), "// widgets policy"); + + captured = captureConsole(); + + await runRlsCommand(["disable", "widgets"]); + + const output = captured.lines.join("\n"); + expect(output).toContain("To disable RLS:"); + expect(output).toContain("Delete the policy file:"); + expect(output).toContain("widgets.policy.ts"); + }); + + it("shows help when no subcommand given (empty array)", async () => { + setupTestProject(); + captured = captureConsole(); + + await runRlsCommand([]); + + const output = captured.lines.join("\n"); + expect(output).toContain("RLS (Row Level Security) Commands"); + expect(output).toContain("bb rls create
"); + expect(output).toContain("bb rls list"); + expect(output).toContain("bb rls disable
"); + }); + + it("shows help for unknown subcommand", async () => { + setupTestProject(); + captured = captureConsole(); + + await runRlsCommand(["unknown"]); + + const output = captured.lines.join("\n"); + expect(output).toContain("RLS (Row Level Security) Commands"); + expect(output).toContain("bb rls create
"); + expect(output).toContain("bb rls list"); + expect(output).toContain("bb rls disable
"); + }); + + it("shows help for undefined subcommand", async () => { + setupTestProject(); + captured = captureConsole(); + + await runRlsCommand([undefined as unknown as string]); + + const output = captured.lines.join("\n"); + expect(output).toContain("RLS (Row Level Security) Commands"); + }); +}); diff --git a/packages/cli/test/integration/rls-test-command.test.ts b/packages/cli/test/integration/rls-test-command.test.ts new file mode 100644 index 0000000..1006b30 --- /dev/null +++ b/packages/cli/test/integration/rls-test-command.test.ts @@ -0,0 +1,534 @@ +/** + * RLS Test Command — Behavioral Tests + * + * Tests for cli/src/commands/rls-test.ts. + * Covers getDatabaseUrl, loadTablePolicies, generatePolicySQL, + * runRLSTestCommand, RLSTestCase, and RLSTestResult types + * without requiring a real PostgreSQL connection. + */ + +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import path from "node:path"; +import { createTestProject } from "../fixtures/fixtures"; +import type { RLSTestCase, RLSTestResult } from "../../src/commands/rls-test"; + +// ── Mock state ──────────────────────────────────────────────────────────────── + +let capturedSqlCalls: string[] = []; +let capturedDbUrl: string | null = null; +let mockDbType: "postgresql" | "sqlite" = "postgresql"; + +function resetCaptures() { + capturedSqlCalls = []; + capturedDbUrl = null; +} + +// ── Env helpers ─────────────────────────────────────────────────────────────── + +function saveEnv() { + return { + DATABASE_URL: process.env.DATABASE_URL, + DB_URL: process.env.DB_URL, + }; +} + +function restoreEnv(orig: ReturnType) { + if (orig.DATABASE_URL !== undefined) process.env.DATABASE_URL = orig.DATABASE_URL; + else delete process.env.DATABASE_URL; + if (orig.DB_URL !== undefined) process.env.DB_URL = orig.DB_URL; + else delete process.env.DB_URL; +} + +// ── Mock: postgres ──────────────────────────────────────────────────────────── + +function createMockSqlClient() { + const sqlFn: any = (first: any, ...rest: any[]) => { + if (Array.isArray(first)) { + const query = String.raw({ raw: first }, ...rest); + capturedSqlCalls.push(query); + if (query.includes("information_schema.columns")) { + if (query.includes("SELECT 1")) { + return Promise.resolve([{ column_name: "user_id" }]); + } + return Promise.resolve([ + { column_name: "id" }, + { column_name: "user_id" }, + { column_name: "created_at" }, + ]); + } + if (query.includes("information_schema.tables")) { + return Promise.resolve([{ 1: 1 }]); + } + if (query.includes("pg_class")) { + return Promise.resolve([{ relrowsecurity: true }]); + } + return Promise.resolve([]); + } + return first; + }; + sqlFn.unsafe = (sqlStr: string) => { + capturedSqlCalls.push(sqlStr); + if (sqlStr.toLowerCase().startsWith("select")) return Promise.resolve([{ row: 1 }]); + return Promise.resolve({}); + }; + sqlFn.end = () => Promise.resolve(); + return sqlFn; +} + +const mockPostgresFn = mock((url: string) => { + capturedDbUrl = url; + return createMockSqlClient(); +}); + +mock.module("postgres", () => ({ + default: mockPostgresFn, +})); + +// ── Mock: migrate-utils (getDatabaseType) ───────────────────────────────────── + +const migrateUtilsPath = path.resolve( + __dirname, + "../../src/commands/migrate-utils.ts", +); + +mock.module(migrateUtilsPath, () => ({ + getDatabaseType: () => mockDbType, + calculateChecksum: () => "", + parseMigrationFilename: () => null, + getMigrationsTableSql: () => "", + loadMigrationFiles: async () => [], +})); + +// ── Dynamic import ──────────────────────────────────────────────────────────── + +const { runRLSTestCommand } = await import("../../src/commands/rls-test"); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("RLS Test Command", () => { + afterEach(() => { + resetCaptures(); + }); + + afterAll(() => { + mock.restore(); + }); + + // ── RLSTestCase type (test 11) ────────────────────────────────────────────── + + describe("RLSTestCase type", () => { + it("has correct shape with all required fields", () => { + const tc: RLSTestCase = { + name: "Can select own rows", + user_id: "user-1", + query: "SELECT * FROM test_table", + expected: "allowed", + }; + + expect(tc).toHaveProperty("name"); + expect(tc).toHaveProperty("user_id"); + expect(tc).toHaveProperty("query"); + expect(tc).toHaveProperty("expected"); + expect(typeof tc.name).toBe("string"); + expect(typeof tc.user_id).toBe("string"); + expect(typeof tc.query).toBe("string"); + expect(tc.expected).toBe("allowed"); + expect(tc.expectedRowCount).toBeUndefined(); + }); + + it("supports optional expectedRowCount field on blocked tests", () => { + const tc: RLSTestCase = { + name: "Cannot see others", + user_id: "user-1", + query: "SELECT * FROM test_table WHERE user_id = 'other'", + expected: "blocked", + expectedRowCount: 0, + }; + + expect(tc.expectedRowCount).toBe(0); + expect(tc.expected).toBe("blocked"); + expect(tc.name).toBe("Cannot see others"); + }); + }); + + // ── RLSTestResult type (test 12) ──────────────────────────────────────────── + + describe("RLSTestResult type", () => { + it("has correct shape with all fields", () => { + const result: RLSTestResult = { + test: "Can select own rows", + passed: true, + actual: "allowed", + expected: "allowed", + rowCount: 1, + }; + + expect(result).toHaveProperty("test"); + expect(result).toHaveProperty("passed"); + expect(result).toHaveProperty("actual"); + expect(result).toHaveProperty("expected"); + expect(typeof result.test).toBe("string"); + expect(typeof result.passed).toBe("boolean"); + expect(result.actual).toBe("allowed"); + expect(result.expected).toBe("allowed"); + expect(result.rowCount).toBe(1); + expect(result.error).toBeUndefined(); + }); + + it("includes optional error field for failure results", () => { + const result: RLSTestResult = { + test: "Cannot select others", + passed: false, + actual: "blocked", + expected: "allowed", + error: "permission denied for table users", + }; + + expect(result.passed).toBe(false); + expect(result.actual).toBe("blocked"); + expect(result.expected).toBe("allowed"); + expect(result.error).toBe("permission denied for table users"); + expect(result.rowCount).toBeUndefined(); + }); + }); + + // ── getDatabaseUrl (tests 1–2) ────────────────────────────────────────────── + + describe("getDatabaseUrl", () => { + it("returns DATABASE_URL when set in env", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/testdb"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + try { + try { + await runRLSTestCommand("/fake/project", "users"); + } catch { + // Expected — no real DB + } + + expect(capturedDbUrl).toBe("postgres://localhost:5432/testdb"); + } finally { + restoreEnv(env); + } + }); + + it("throws when DATABASE_URL is not set", async () => { + const env = saveEnv(); + delete process.env.DATABASE_URL; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + try { + await expect( + runRLSTestCommand("/fake/project", "users"), + ).rejects.toThrow( + "DATABASE_URL not found in environment. Please ensure you have a PostgreSQL database configured.", + ); + + expect(capturedDbUrl).toBeNull(); + } finally { + restoreEnv(env); + } + }); + }); + + // ── loadTablePolicies (tests 3–4) ─────────────────────────────────────────── + + describe("loadTablePolicies", () => { + it("returns defaults when no policies directory exists", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/db"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + const proj = createTestProject({}); + + try { + try { + await runRLSTestCommand(proj.root, "users"); + } catch { + // Expected — no real DB + } + + const policyStmts = capturedSqlCalls.filter((s) => + s.toUpperCase().includes("CREATE POLICY"), + ); + + expect(policyStmts.length).toBe(4); + for (const stmt of policyStmts) { + expect(stmt).toContain("auth.uid() = user_id"); + } + } finally { + proj.cleanup(); + restoreEnv(env); + } + }); + + it("reads policy files correctly and extracts operations", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/db"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + const proj = createTestProject({ + "src/db/policies/users_select.policy.ts": ` + export default { + select: "auth.uid() = owner_id", + }; + `, + "src/db/policies/users_insert.policy.ts": ` + export default { + insert: "auth.uid() IS NOT NULL", + }; + `, + }); + + try { + try { + await runRLSTestCommand(proj.root, "users"); + } catch { + // Expected — no real DB + } + + const policyStmts = capturedSqlCalls.filter((s) => + s.toUpperCase().includes("CREATE POLICY"), + ); + + expect(policyStmts.length).toBe(2); + + const selectStmt = policyStmts.find((s) => s.includes("FOR SELECT")); + expect(selectStmt).toBeDefined(); + expect(selectStmt!).toContain("auth.uid() = owner_id"); + + const insertStmt = policyStmts.find((s) => s.includes("FOR INSERT")); + expect(insertStmt).toBeDefined(); + expect(insertStmt!).toContain("auth.uid() IS NOT NULL"); + expect(insertStmt!).toContain("WITH CHECK"); + } finally { + proj.cleanup(); + restoreEnv(env); + } + }); + + it("returns defaults when no matching .policy.ts files found for table", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/db"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + const proj = createTestProject({ + "src/db/policies/other_table.policy.ts": ` + export default { select: "auth.uid() = user_id" }; + `, + }); + + try { + try { + await runRLSTestCommand(proj.root, "users"); + } catch { + // Expected — no real DB + } + + const policyStmts = capturedSqlCalls.filter((s) => + s.toUpperCase().includes("CREATE POLICY"), + ); + + expect(policyStmts.length).toBe(4); + for (const stmt of policyStmts) { + expect(stmt).toContain("auth.uid() = user_id"); + } + } finally { + proj.cleanup(); + restoreEnv(env); + } + }); + }); + + // ── generatePolicySQL (tests 5–8) ─────────────────────────────────────────── + + describe("generatePolicySQL", () => { + it("generates CREATE POLICY for select only", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/db"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + const proj = createTestProject({ + "src/db/policies/users_policy.policy.ts": ` + export default { + select: "auth.uid() = user_id", + }; + `, + }); + + try { + try { + await runRLSTestCommand(proj.root, "users"); + } catch { + // Expected — no real DB + } + + const policyStmts = capturedSqlCalls.filter((s) => + s.toUpperCase().includes("CREATE POLICY"), + ); + + expect(policyStmts.length).toBe(1); + expect(policyStmts[0]).toContain("FOR SELECT USING ("); + expect(policyStmts[0]).not.toContain("FOR INSERT"); + expect(policyStmts[0]).not.toContain("FOR UPDATE"); + expect(policyStmts[0]).not.toContain("FOR DELETE"); + } finally { + proj.cleanup(); + restoreEnv(env); + } + }); + + it("generates CREATE POLICY for select + insert", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/db"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + const proj = createTestProject({ + "src/db/policies/users_policy.policy.ts": ` + export default { + select: "auth.uid() = user_id", + insert: "auth.uid() = user_id", + }; + `, + }); + + try { + try { + await runRLSTestCommand(proj.root, "users"); + } catch { + // Expected — no real DB + } + + const policyStmts = capturedSqlCalls.filter((s) => + s.toUpperCase().includes("CREATE POLICY"), + ); + + expect(policyStmts.length).toBe(2); + expect(policyStmts[0]).toContain("FOR SELECT USING ("); + expect(policyStmts[1]).toContain("FOR INSERT WITH CHECK ("); + } finally { + proj.cleanup(); + restoreEnv(env); + } + }); + + it("generates CREATE POLICY for all operations", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/db"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + const proj = createTestProject({ + "src/db/policies/users_policy.policy.ts": ` + export default { + select: "auth.uid() = user_id", + insert: "auth.uid() = user_id", + update: "auth.uid() = user_id", + delete: "auth.uid() = user_id", + }; + `, + }); + + try { + try { + await runRLSTestCommand(proj.root, "users"); + } catch { + // Expected — no real DB + } + + const policyStmts = capturedSqlCalls.filter((s) => + s.toUpperCase().includes("CREATE POLICY"), + ); + + expect(policyStmts.length).toBe(1); + const combined = policyStmts[0]; + expect(combined).toContain("FOR SELECT USING ("); + expect(combined).toContain("FOR INSERT WITH CHECK ("); + expect(combined).toContain("FOR UPDATE USING ("); + expect(combined).toContain("FOR DELETE USING ("); + expect((combined.match(/CREATE POLICY/gi) || []).length).toBe(4); + } finally { + proj.cleanup(); + restoreEnv(env); + } + }); + + it("returns empty string when policy file has no operations", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "postgres://localhost:5432/db"; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + const proj = createTestProject({ + "src/db/policies/users_empty.policy.ts": ` + export const policy = { + name: "test_policy", + }; + `, + }); + + try { + try { + await runRLSTestCommand(proj.root, "users"); + } catch { + // Expected — no real DB + } + + const policyStmts = capturedSqlCalls.filter((s) => + s.toUpperCase().includes("CREATE POLICY"), + ); + + expect(policyStmts.length).toBe(0); + } finally { + proj.cleanup(); + restoreEnv(env); + } + }); + }); + + // ── runRLSTestCommand DB type validation (tests 9–10) ─────────────────────── + + describe("runRLSTestCommand database type validation", () => { + it("rejects non-PostgreSQL database type", async () => { + const env = saveEnv(); + process.env.DATABASE_URL = "file:./local.db"; + delete process.env.DB_URL; + mockDbType = "sqlite"; + + try { + await expect( + runRLSTestCommand("/fake/project", "users"), + ).rejects.toThrow( + "RLS testing is only supported for PostgreSQL databases. Current: sqlite", + ); + } finally { + restoreEnv(env); + } + }); + + it("throws when DATABASE_URL is missing", async () => { + const env = saveEnv(); + delete process.env.DATABASE_URL; + delete process.env.DB_URL; + mockDbType = "postgresql"; + + try { + await expect( + runRLSTestCommand("/fake/project", "users"), + ).rejects.toThrow("DATABASE_URL not found in environment"); + } finally { + restoreEnv(env); + } + }); + }); +}); diff --git a/packages/cli/test/integration/storage-commands.test.ts b/packages/cli/test/integration/storage-commands.test.ts new file mode 100644 index 0000000..0b69f89 --- /dev/null +++ b/packages/cli/test/integration/storage-commands.test.ts @@ -0,0 +1,455 @@ +/** + * Storage Commands — Integration Behavioral Tests + * + * Tests runStorageBucketsListCommand and runStorageUploadCommand with mocked + * @betterbase/core/storage and config loader. Internal helpers (formatBytes, + * getContentType, getStorageConfigFromEnv, generateStorageConfigBlock, etc.) + * are exercised indirectly through exported command output and spy assertions. + */ + +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { createTestProject } from "../fixtures/fixtures"; + +// ── Mutable mock state ─────────────────────────────────────────────────────── +let mockConfigResult: any = null; + +let mockListObjectsResult: Array<{ key: string; size: number; lastModified: Date }> = [ + { key: "file1.txt", size: 1024, lastModified: new Date("2024-01-15") }, + { key: "folder/file2.png", size: 2048, lastModified: new Date("2024-01-16") }, +]; + +let mockUploadResult = { key: "uploaded.txt", size: 512, contentType: "text/plain" }; +let mockGetPublicUrlResult = "https://test-bucket.s3.us-east-1.amazonaws.com/uploaded.txt"; +let mockCreateSignedUrlResult = "https://signed.example.com/uploaded.txt?token=abc"; + +// ── Spies ──────────────────────────────────────────────────────────────────── +const listObjectsSpy = mock(async (_bucket: string) => { + if (mockListObjectsResult.length > 0) { + return [...mockListObjectsResult]; + } + return []; +}); + +const uploadSpy = mock( + async (_bucket: string, _key: string, _content: Buffer, _opts?: any) => ({ + ...mockUploadResult, + }), +); + +const getPublicUrlSpy = mock((_bucket: string, _key: string) => mockGetPublicUrlResult); + +const createSignedUrlSpy = mock(async (_bucket: string, _key: string, _opts?: any) => + mockCreateSignedUrlResult, +); + +// ── Module mocks (must precede dynamic import) ─────────────────────────────── +mock.module("@betterbase/core/storage", () => ({ + createS3Adapter: () => ({ + listObjects: listObjectsSpy, + upload: uploadSpy, + getPublicUrl: getPublicUrlSpy, + createSignedUrl: createSignedUrlSpy, + }), + createStorage: () => ({}), +})); + +const configModulePath = path.resolve(__dirname, "../../src/utils/config.ts"); +mock.module(configModulePath, () => ({ + loadConfig: async () => mockConfigResult, + findConfigFile: async () => null, + readConfigFile: async () => null, +})); + +// ── Dynamic import ─────────────────────────────────────────────────────────── +const { runStorageBucketsListCommand, runStorageUploadCommand } = await import( + "../../src/commands/storage" +); + +// ── Helpers ────────────────────────────────────────────────────────────────── +const VALID_CONFIG = { + storage: { provider: "s3", bucket: "test-bucket", region: "us-east-1" }, +}; + +const ENV_CREDS = { + STORAGE_ACCESS_KEY: "test-access-key", + STORAGE_SECRET_KEY: "test-secret-key", +}; + +function setEnv(vars: Record) { + for (const [k, v] of Object.entries(vars)) { + process.env[k] = v; + } +} + +function clearEnv(vars: Record) { + for (const k of Object.keys(vars)) { + delete process.env[k]; + } +} + +function resetMocks() { + mockConfigResult = null; + mockListObjectsResult = [ + { key: "file1.txt", size: 1024, lastModified: new Date("2024-01-15") }, + { key: "folder/file2.png", size: 2048, lastModified: new Date("2024-01-16") }, + ]; + mockUploadResult = { key: "uploaded.txt", size: 512, contentType: "text/plain" }; + mockGetPublicUrlResult = "https://test-bucket.s3.us-east-1.amazonaws.com/uploaded.txt"; + mockCreateSignedUrlResult = "https://signed.example.com/uploaded.txt?token=abc"; + + listObjectsSpy.mockClear(); + uploadSpy.mockClear(); + getPublicUrlSpy.mockClear(); + createSignedUrlSpy.mockClear(); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// runStorageBucketsListCommand +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runStorageBucketsListCommand", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + afterEach(() => { + resetMocks(); + clearEnv(ENV_CREDS); + clearEnv({ + STORAGE_PROVIDER: "", + STORAGE_BUCKET: "", + STORAGE_REGION: "", + STORAGE_ACCESS_KEY_ID: "", + STORAGE_SECRET_ACCESS_KEY: "", + }); + logSpy?.mockRestore(); + errorSpy?.mockRestore(); + }); + + it("errors when storage is not configured (no config, no env)", async () => { + mockConfigResult = null; + logSpy = spyOn(console, "log").mockImplementation(() => {}); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageBucketsListCommand("/fake/project"); + + const errorCalls = errorSpy.mock.calls.flat().join(""); + expect(errorCalls).toContain("not configured"); + expect(listObjectsSpy).not.toHaveBeenCalled(); + }); + + it("lists objects when config and env credentials are provided", async () => { + const t = createTestProject(); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageBucketsListCommand(t.root); + + expect(listObjectsSpy).toHaveBeenCalled(); + expect(listObjectsSpy.mock.calls[0][0]).toBe("test-bucket"); + + const logOutput = logSpy.mock.calls.flat().join(""); + expect(logOutput).toContain("test-bucket"); + expect(logOutput).toContain("file1.txt"); + expect(logOutput).toContain("folder/file2.png"); + expect(logOutput).toContain("1 KB"); + expect(logOutput).toContain("2 KB"); + expect(logOutput).toContain("Total: 2"); + + t.cleanup(); + }); + + it("shows empty bucket message when bucket has no objects", async () => { + const t = createTestProject(); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + mockListObjectsResult = []; + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageBucketsListCommand(t.root); + + const logOutput = logSpy.mock.calls.flat().join(""); + expect(logOutput).toContain("empty"); + + t.cleanup(); + }); + + it("errors when config exists but credentials are missing from env", async () => { + const t = createTestProject(); + mockConfigResult = VALID_CONFIG; + // no ENV_CREDS set + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageBucketsListCommand(t.root); + + expect(listObjectsSpy).not.toHaveBeenCalled(); + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("credentials"); + + t.cleanup(); + }); + + it("works with env-only config (getStorageConfigFromEnv path)", async () => { + const t = createTestProject(); + mockConfigResult = null; + setEnv({ + STORAGE_PROVIDER: "s3", + STORAGE_BUCKET: "env-bucket", + STORAGE_REGION: "us-west-2", + STORAGE_ACCESS_KEY_ID: "env-ak", + STORAGE_SECRET_ACCESS_KEY: "env-sk", + }); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageBucketsListCommand(t.root); + + expect(listObjectsSpy).toHaveBeenCalled(); + expect(listObjectsSpy.mock.calls[0][0]).toBe("env-bucket"); + + const logOutput = logSpy.mock.calls.flat().join(""); + expect(logOutput).toContain("env-bucket"); + + clearEnv({ + STORAGE_PROVIDER: "", + STORAGE_BUCKET: "", + STORAGE_REGION: "", + STORAGE_ACCESS_KEY_ID: "", + STORAGE_SECRET_ACCESS_KEY: "", + }); + t.cleanup(); + }); + + it("returns null when STORAGE_BUCKET is missing from env config", async () => { + const t = createTestProject(); + mockConfigResult = null; + setEnv({ STORAGE_PROVIDER: "s3" }); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageBucketsListCommand(t.root); + + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("not configured"); + expect(listObjectsSpy).not.toHaveBeenCalled(); + + clearEnv({ STORAGE_PROVIDER: "" }); + t.cleanup(); + }); + + it("handles adapter errors gracefully", async () => { + const t = createTestProject(); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + listObjectsSpy.mockImplementationOnce(async () => { + throw new Error("Connection refused"); + }); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageBucketsListCommand(t.root); + + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("Failed to list buckets"); + expect(errorOutput).toContain("Connection refused"); + + t.cleanup(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// runStorageUploadCommand +// ═══════════════════════════════════════════════════════════════════════════════ +describe("runStorageUploadCommand", () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + afterEach(() => { + resetMocks(); + clearEnv(ENV_CREDS); + clearEnv({ + STORAGE_PROVIDER: "", + STORAGE_BUCKET: "", + STORAGE_REGION: "", + STORAGE_ACCESS_KEY_ID: "", + STORAGE_SECRET_ACCESS_KEY: "", + }); + logSpy?.mockRestore(); + errorSpy?.mockRestore(); + }); + + it("errors when file path is empty", async () => { + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageUploadCommand(""); + + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("File path is required"); + expect(uploadSpy).not.toHaveBeenCalled(); + }); + + it("errors when file does not exist", async () => { + const t = createTestProject(); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageUploadCommand("nonexistent.txt", { projectRoot: t.root }); + + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("File not found"); + expect(uploadSpy).not.toHaveBeenCalled(); + + t.cleanup(); + }); + + it("uploads file and displays details including formatBytes output", async () => { + const t = createTestProject(); + const fileContent = "Hello, BetterBase! This is a test file for upload."; + writeFileSync(path.join(t.root, "hello.txt"), fileContent); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageUploadCommand("hello.txt", { projectRoot: t.root }); + + expect(uploadSpy).toHaveBeenCalled(); + const uploadArgs = uploadSpy.mock.calls[0]; + expect(uploadArgs[0]).toBe("test-bucket"); + expect(uploadArgs[1]).toContain("hello.txt"); + expect(uploadArgs[3]).toEqual({ contentType: "text/plain" }); + + const logOutput = logSpy.mock.calls.flat().join(""); + // formatBytes should show the file size + expect(logOutput).toContain(`${fileContent.length}`); + expect(logOutput).toContain("Upload complete"); + expect(logOutput).toContain("test-bucket"); + + t.cleanup(); + }); + + it("determines correct content type from file extension", async () => { + const t = createTestProject(); + writeFileSync(path.join(t.root, "icon.png"), Buffer.from("fake-png")); + writeFileSync(path.join(t.root, "data.json"), '{"ok":true}'); + writeFileSync(path.join(t.root, "page.html"), ""); + writeFileSync(path.join(t.root, "unknown.xyz"), "???"); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + // png + await runStorageUploadCommand("icon.png", { projectRoot: t.root }); + expect(uploadSpy.mock.calls[0][3]).toEqual({ contentType: "image/png" }); + + // json + await runStorageUploadCommand("data.json", { projectRoot: t.root }); + expect(uploadSpy.mock.calls[1][3]).toEqual({ contentType: "application/json" }); + + // html + await runStorageUploadCommand("page.html", { projectRoot: t.root }); + expect(uploadSpy.mock.calls[2][3]).toEqual({ contentType: "text/html" }); + + // unknown + await runStorageUploadCommand("unknown.xyz", { projectRoot: t.root }); + expect(uploadSpy.mock.calls[3][3]).toEqual({ contentType: "application/octet-stream" }); + + t.cleanup(); + }); + + it("errors when storage is not configured", async () => { + const t = createTestProject(); + writeFileSync(path.join(t.root, "data.txt"), "test"); + mockConfigResult = null; + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageUploadCommand("data.txt", { projectRoot: t.root }); + + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("not configured"); + expect(uploadSpy).not.toHaveBeenCalled(); + + t.cleanup(); + }); + + it("errors when config exists but credentials are missing", async () => { + const t = createTestProject(); + writeFileSync(path.join(t.root, "data.txt"), "test"); + mockConfigResult = VALID_CONFIG; + // no ENV_CREDS + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageUploadCommand("data.txt", { projectRoot: t.root }); + + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("credentials"); + expect(uploadSpy).not.toHaveBeenCalled(); + + t.cleanup(); + }); + + it("handles upload adapter errors", async () => { + const t = createTestProject(); + writeFileSync(path.join(t.root, "data.txt"), "test"); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + uploadSpy.mockImplementationOnce(async () => { + throw new Error("Bucket not found"); + }); + errorSpy = spyOn(console, "error").mockImplementation(() => {}); + + await runStorageUploadCommand("data.txt", { projectRoot: t.root }); + + const errorOutput = errorSpy.mock.calls.flat().join(""); + expect(errorOutput).toContain("Upload failed"); + expect(errorOutput).toContain("Bucket not found"); + + t.cleanup(); + }); + + it("uses custom bucket option when provided", async () => { + const t = createTestProject(); + writeFileSync(path.join(t.root, "data.txt"), "test"); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageUploadCommand("data.txt", { + projectRoot: t.root, + bucket: "custom-bucket", + }); + + expect(uploadSpy.mock.calls[0][0]).toBe("custom-bucket"); + + t.cleanup(); + }); + + it("uses custom remote path when provided", async () => { + const t = createTestProject(); + writeFileSync(path.join(t.root, "data.txt"), "test"); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageUploadCommand("data.txt", { + projectRoot: t.root, + path: "uploads/renamed.txt", + }); + + expect(uploadSpy.mock.calls[0][1]).toBe("uploads/renamed.txt"); + + t.cleanup(); + }); + + it("resolves absolute file paths correctly", async () => { + const t = createTestProject(); + const absPath = path.join(t.root, "absolute.txt"); + writeFileSync(absPath, "absolute"); + mockConfigResult = VALID_CONFIG; + setEnv(ENV_CREDS); + logSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runStorageUploadCommand(absPath, { projectRoot: "/some/other/dir" }); + + expect(uploadSpy).toHaveBeenCalled(); + + t.cleanup(); + }); +}); diff --git a/packages/cli/test/integration/webhook-commands.test.ts b/packages/cli/test/integration/webhook-commands.test.ts new file mode 100644 index 0000000..59ea1e4 --- /dev/null +++ b/packages/cli/test/integration/webhook-commands.test.ts @@ -0,0 +1,778 @@ +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { Database } from "bun:sqlite"; +import { createTestProject } from "../fixtures/fixtures"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const VALID_CONFIG_JS = ` +export default { + project: { name: "test-project" }, + provider: { type: "managed" }, +}; +`; + +function configWithWebhooks( + webhooks: Array<{ + id: string; + table: string; + events: string[]; + url: string; + secret: string; + enabled: boolean; + }>, +): string { + return ` +export default { + project: { name: "test-project" }, + provider: { type: "managed" }, + webhooks: ${JSON.stringify(webhooks, null, 2)}, +}; +`; +} + +function createProject(files: Record): string { + const root = join(tmpdir(), `bb-webhook-test-${randomUUID().slice(0, 8)}`); + mkdirSync(root, { recursive: true }); + for (const [relPath, content] of Object.entries(files)) { + const absPath = join(root, relPath); + mkdirSync(join(absPath, ".."), { recursive: true }); + writeFileSync(absPath, content); + } + return root; +} + +function cleanupProject(root: string): void { + try { + rmSync(root, { recursive: true, force: true }); + } catch { + /* ignore */ + } +} + +function captureConsole() { + const lines: string[] = []; + const logSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const errorSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const warnSpy = mock((...args: unknown[]) => { + lines.push(args.map(String).join(" ")); + }); + const origLog = console.log; + const origError = console.error; + const origWarn = console.warn; + console.log = logSpy as unknown as typeof console.log; + console.error = errorSpy as unknown as typeof console.error; + console.warn = warnSpy as unknown as typeof console.warn; + return { + lines, + restore: () => { + console.log = origLog; + console.error = origError; + console.warn = origWarn; + }, + }; +} + +function createDbDir(root: string, deliveries: DeliverySeed[]): string { + const dbDir = join(root, ".betterbase"); + mkdirSync(dbDir, { recursive: true }); + const dbPath = join(dbDir, "dev.db"); + const db = new Database(dbPath); + db.run(` + CREATE TABLE IF NOT EXISTS _betterbase_webhook_deliveries ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'pending')), + request_url TEXT NOT NULL, + request_body TEXT, + response_code INTEGER, + response_body TEXT, + error TEXT, + attempt_count INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + const stmt = db.prepare( + `INSERT INTO _betterbase_webhook_deliveries + (id, webhook_id, status, request_url, request_body, response_code, response_body, error, attempt_count, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const d of deliveries) { + stmt.run(d.id, d.webhook_id, d.status, d.request_url ?? "https://example.com/webhook", d.request_body ?? null, d.response_code ?? null, d.response_body ?? null, d.error ?? null, d.attempt_count ?? 1, d.created_at); + } + db.close(); + return root; +} + +interface DeliverySeed { + id: string; + webhook_id: string; + status: "success" | "failed" | "pending"; + request_url?: string; + response_code?: number; + response_body?: string; + request_body?: string; + error?: string; + attempt_count?: number; + created_at: string; +} + +// --------------------------------------------------------------------------- +// Test suites +// --------------------------------------------------------------------------- + +describe("runWebhookListCommand", () => { + let projectRoot: string; + let captured: ReturnType; + + afterEach(() => { + captured?.restore(); + if (projectRoot) cleanupProject(projectRoot); + delete process.env.WEBHOOK_USERS_URL; + delete process.env.WEBHOOK_SECRET; + delete process.env.WEBHOOK_ORDERS_URL; + delete process.env.WEBHOOK_ORDERS_SECRET; + }); + + it("lists all configured webhooks from the config", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-abc123", + table: "users", + events: ["INSERT", "UPDATE"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + { + id: "webhook-def456", + table: "orders", + events: ["INSERT", "UPDATE", "DELETE"], + url: "process.env.WEBHOOK_ORDERS_URL", + secret: "process.env.WEBHOOK_ORDERS_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookListCommand } = await import("../../src/commands/webhook"); + await runWebhookListCommand(projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhooks"); + expect(output).toContain("webhook-abc123"); + expect(output).toContain("webhook-def456"); + expect(output).toContain("users"); + expect(output).toContain("orders"); + // Header columns are present + expect(output).toContain("ID"); + expect(output).toContain("Table"); + expect(output).toContain("Events"); + expect(output).toContain("Status"); + }); + + it("shows message when no webhooks are configured", async () => { + projectRoot = createProject({ + "betterbase.config.js": VALID_CONFIG_JS, + }); + + captured = captureConsole(); + const { runWebhookListCommand } = await import("../../src/commands/webhook"); + await runWebhookListCommand(projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("No webhooks configured"); + }); + + it("shows disabled webhooks with correct status label", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-abc123", + table: "users", + events: ["INSERT", "UPDATE"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: false, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookListCommand } = await import("../../src/commands/webhook"); + await runWebhookListCommand(projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("webhook-abc123"); + expect(output).toContain("disabled"); + }); + + it("shows event types comma separated", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-abc123", + table: "users", + events: ["INSERT", "UPDATE", "DELETE"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookListCommand } = await import("../../src/commands/webhook"); + await runWebhookListCommand(projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("INSERT"); + expect(output).toContain("UPDATE"); + expect(output).toContain("DELETE"); + }); + + it("returns early when config is missing", async () => { + // No config file at all + projectRoot = createProject({}); + + captured = captureConsole(); + const { runWebhookListCommand } = await import("../../src/commands/webhook"); + await runWebhookListCommand(projectRoot); + + // Should not throw; simply returns with no output (or a warning from loadConfig) + // Verify the function does not produce the Webhooks header + const output = captured.lines.join("\n"); + expect(output).not.toContain("Webhooks"); + }); +}); + +describe("runWebhookTestCommand", () => { + let projectRoot: string; + let captured: ReturnType; + + afterEach(() => { + captured?.restore(); + mock.restore(); + if (projectRoot) cleanupProject(projectRoot); + delete process.env.WEBHOOK_USERS_URL; + delete process.env.WEBHOOK_SECRET; + }); + + it("errors when webhook ID is not found in config", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-existing", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookTestCommand } = await import("../../src/commands/webhook"); + await runWebhookTestCommand(projectRoot, "webhook-nonexistent"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhook not found"); + expect(output).toContain("webhook-nonexistent"); + }); + + it("errors when URL environment variable is not set", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-test-url", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookTestCommand } = await import("../../src/commands/webhook"); + await runWebhookTestCommand(projectRoot, "webhook-test-url"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Environment variable not set"); + expect(output).toContain("WEBHOOK_USERS_URL"); + }); + + it("errors when secret environment variable is not set", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-test-secret", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + process.env.WEBHOOK_USERS_URL = "https://example.com/webhook"; + // WEBHOOK_SECRET is intentionally NOT set + + captured = captureConsole(); + const { runWebhookTestCommand } = await import("../../src/commands/webhook"); + await runWebhookTestCommand(projectRoot, "webhook-test-secret"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Environment variable not set"); + expect(output).toContain("WEBHOOK_SECRET"); + }); + + it("config validation rejects URLs and secrets not using env var references", async () => { + // The zod schema enforces that url and secret must start with "process.env." + // loadConfig returns null when validation fails, and the test command returns early. + projectRoot = createProject({ + "betterbase.config.js": ` +export default { + project: { name: "test-project" }, + provider: { type: "managed" }, + webhooks: [ + { + id: "webhook-bad", + table: "users", + events: ["INSERT"], + url: "https://hardcoded.example.com", + secret: "hardcoded-secret", + enabled: true, + }, + ], +}; +`, + }); + + captured = captureConsole(); + const { runWebhookTestCommand } = await import("../../src/commands/webhook"); + await runWebhookTestCommand(projectRoot, "webhook-bad"); + + const output = captured.lines.join("\n"); + // loadConfig warns about the schema validation failure + expect(output).toContain("Config validation"); + expect(output).toContain("environment variable reference"); + }); + + it("sends a test payload when all env vars are set", async () => { + // Mock WebhookDispatcher BEFORE importing the webhook module + mock.module("@betterbase/core/webhooks", () => ({ + WebhookDispatcher: class { + constructor(_configs: unknown[]) {} + testWebhook = async () => ({ + success: true, + status_code: 200, + response_body: "ok", + }); + }, + })); + + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-ok", + table: "users", + events: ["INSERT", "UPDATE"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + process.env.WEBHOOK_USERS_URL = "https://example.com/webhook"; + process.env.WEBHOOK_SECRET = "my-secret-token"; + + captured = captureConsole(); + const { runWebhookTestCommand } = await import("../../src/commands/webhook"); + await runWebhookTestCommand(projectRoot, "webhook-ok"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Testing webhook"); + expect(output).toContain("webhook-ok"); + expect(output).toContain("https://example.com/webhook"); + expect(output).toContain("Webhook test succeeded"); + }); + + it("reports failure when test webhook returns success: false", async () => { + mock.module("@betterbase/core/webhooks", () => ({ + WebhookDispatcher: class { + constructor(_configs: unknown[]) {} + testWebhook = async () => ({ + success: false, + status_code: 500, + response_body: "Internal Server Error", + error: "timeout", + }); + }, + })); + + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-fail", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + process.env.WEBHOOK_USERS_URL = "https://example.com/webhook"; + process.env.WEBHOOK_SECRET = "my-secret-token"; + + captured = captureConsole(); + const { runWebhookTestCommand } = await import("../../src/commands/webhook"); + await runWebhookTestCommand(projectRoot, "webhook-fail"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhook test failed"); + expect(output).toContain("500"); + expect(output).toContain("timeout"); + }); +}); + +describe("runWebhookLogsCommand", () => { + let projectRoot: string; + let captured: ReturnType; + + afterEach(() => { + captured?.restore(); + if (projectRoot) cleanupProject(projectRoot); + delete process.env.WEBHOOK_USERS_URL; + delete process.env.WEBHOOK_SECRET; + }); + + it("displays delivery logs from the local database", async () => { + projectRoot = createDbDir( + createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-logs-test", + table: "users", + events: ["INSERT", "UPDATE"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }), + [ + { + id: "delivery-001", + webhook_id: "webhook-logs-test", + status: "success", + response_code: 200, + created_at: "2025-01-15T10:30:00Z", + }, + { + id: "delivery-002", + webhook_id: "webhook-logs-test", + status: "failed", + response_code: 500, + error: "timeout", + created_at: "2025-01-15T10:31:00Z", + }, + ], + ); + + captured = captureConsole(); + const { runWebhookLogsCommand } = await import("../../src/commands/webhook"); + await runWebhookLogsCommand(projectRoot, "webhook-logs-test"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhook"); + expect(output).toContain("webhook-logs-test"); + expect(output).toContain("Delivery Logs"); + expect(output).toContain("success"); + expect(output).toContain("failed"); + expect(output).toContain("Total:"); + }); + + it("shows message when no delivery logs exist", async () => { + projectRoot = createDbDir( + createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-empty-logs", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }), + [], // No deliveries + ); + + captured = captureConsole(); + const { runWebhookLogsCommand } = await import("../../src/commands/webhook"); + await runWebhookLogsCommand(projectRoot, "webhook-empty-logs"); + + const output = captured.lines.join("\n"); + expect(output).toContain("No delivery logs found"); + }); + + it("shows error when the database file does not exist", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-nodb", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookLogsCommand } = await import("../../src/commands/webhook"); + await runWebhookLogsCommand(projectRoot, "webhook-nodb"); + + const output = captured.lines.join("\n"); + expect(output).toContain("No local database found"); + }); + + it("errors when webhook ID is not found", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-known", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookLogsCommand } = await import("../../src/commands/webhook"); + await runWebhookLogsCommand(projectRoot, "webhook-unknown"); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhook not found"); + expect(output).toContain("webhook-unknown"); + }); + + it("respects the limit option when querying logs", async () => { + projectRoot = createDbDir( + createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-limit-test", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }), + [ + { id: "d-1", webhook_id: "webhook-limit-test", status: "success", response_code: 200, created_at: "2025-01-15T10:30:00Z" }, + { id: "d-2", webhook_id: "webhook-limit-test", status: "success", response_code: 200, created_at: "2025-01-15T10:31:00Z" }, + { id: "d-3", webhook_id: "webhook-limit-test", status: "success", response_code: 200, created_at: "2025-01-15T10:32:00Z" }, + { id: "d-4", webhook_id: "webhook-limit-test", status: "success", response_code: 200, created_at: "2025-01-15T10:33:00Z" }, + { id: "d-5", webhook_id: "webhook-limit-test", status: "success", response_code: 200, created_at: "2025-01-15T10:34:00Z" }, + ], + ); + + captured = captureConsole(); + const { runWebhookLogsCommand } = await import("../../src/commands/webhook"); + await runWebhookLogsCommand(projectRoot, "webhook-limit-test", { limit: 3 }); + + const output = captured.lines.join("\n"); + expect(output).toContain("Limit"); + expect(output).toContain("3"); + expect(output).toContain("Total: 3"); + }); +}); + +describe("runWebhookCommand routing", () => { + let projectRoot: string; + let captured: ReturnType; + + afterEach(() => { + captured?.restore(); + mock.restore(); + if (projectRoot) cleanupProject(projectRoot); + }); + + it("routes 'list' to list command and shows webhooks", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "wh-list", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookCommand } = await import("../../src/commands/webhook"); + await runWebhookCommand(["list"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhooks"); + expect(output).toContain("wh-list"); + }); + + it("routes 'test' to test command", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-routing-test", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookCommand } = await import("../../src/commands/webhook"); + await runWebhookCommand(["test", "webhook-nonexistent"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhook not found"); + }); + + it("routes 'logs' to logs command", async () => { + projectRoot = createProject({ + "betterbase.config.js": configWithWebhooks([ + { + id: "webhook-routing-logs", + table: "users", + events: ["INSERT"], + url: "process.env.WEBHOOK_USERS_URL", + secret: "process.env.WEBHOOK_SECRET", + enabled: true, + }, + ]), + }); + + captured = captureConsole(); + const { runWebhookCommand } = await import("../../src/commands/webhook"); + await runWebhookCommand(["logs", "webhook-unknown"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Webhook not found"); + }); + + it("shows help when no subcommand is provided", async () => { + projectRoot = createProject({ + "betterbase.config.js": VALID_CONFIG_JS, + }); + + captured = captureConsole(); + const { runWebhookCommand } = await import("../../src/commands/webhook"); + await runWebhookCommand([], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("BetterBase Webhook Commands"); + expect(output).toContain("create"); + expect(output).toContain("list"); + expect(output).toContain("test "); + expect(output).toContain("logs "); + }); + + it("shows usage error when 'test' has no webhook ID", async () => { + projectRoot = createProject({ + "betterbase.config.js": VALID_CONFIG_JS, + }); + + captured = captureConsole(); + const { runWebhookCommand } = await import("../../src/commands/webhook"); + await runWebhookCommand(["test"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Usage: bb webhook test "); + }); + + it("shows usage error when 'logs' has no webhook ID", async () => { + projectRoot = createProject({ + "betterbase.config.js": VALID_CONFIG_JS, + }); + + captured = captureConsole(); + const { runWebhookCommand } = await import("../../src/commands/webhook"); + await runWebhookCommand(["logs"], projectRoot); + + const output = captured.lines.join("\n"); + expect(output).toContain("Usage: bb webhook logs "); + }); +}); + +describe("generateWebhookId", () => { + it("generateWebhookId creates ID with correct format", async () => { + const { generateWebhookId } = await import("../../src/commands/webhook"); + const id = generateWebhookId(); + expect(id).toMatch(/^webhook-[0-9a-z]+$/); + }); + + it("IDs are unique across calls", async () => { + const { generateWebhookId } = await import("../../src/commands/webhook"); + const id1 = generateWebhookId(); + await new Promise(r => setTimeout(r, 10)); + const id2 = generateWebhookId(); + expect(id2).toMatch(/^webhook-[0-9a-z]+$/); + expect(id2).not.toBe(id1); + expect(id2.localeCompare(id1)).toBeGreaterThan(0); + }); +}); + +describe("runWebhookCreateCommand helpers", () => { + let projectRoot: string; + let captured: ReturnType; + + afterEach(() => { + captured?.restore(); + if (projectRoot) cleanupProject(projectRoot); + }); + + it("returns early when config file does not exist", async () => { + projectRoot = createProject({}); + captured = captureConsole(); + + const { runWebhookCreateCommand } = await import("../../src/commands/webhook"); + + try { + await runWebhookCreateCommand(projectRoot); + } finally { + captured.restore(); + } + + const output = captured.lines.join("\n"); + expect(output).toContain("Could not load config"); + }); +}); diff --git a/packages/cli/test/logger.test.ts b/packages/cli/test/logger.test.ts index 413af03..2c5c4cf 100644 --- a/packages/cli/test/logger.test.ts +++ b/packages/cli/test/logger.test.ts @@ -1,81 +1,615 @@ -import { describe, expect, it } from "bun:test"; -import * as logger from "../src/utils/logger"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; + +// Save and restore FORCE_COLOR to prevent leaking to other tests +const origForceColor = process.env.FORCE_COLOR; +process.env.FORCE_COLOR = "1"; + +// Dynamically import logger after forcing color support +let logger!: typeof import("../src/utils/logger"); + +beforeAll(async () => { + logger = await import("../src/utils/logger"); +}); + +afterAll(() => { + if (origForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = origForceColor; + } +}); + +function stripAnsi(str: string): string { + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +let spyLog: ReturnType; +let spyError: ReturnType; +let spyWarn: ReturnType; +const origLog = console.log; +const origError = console.error; +const origWarn = console.warn; + +beforeEach(() => { + spyLog = mock((..._args: unknown[]) => {}); + spyError = mock((..._args: unknown[]) => {}); + spyWarn = mock((..._args: unknown[]) => {}); + console.log = spyLog as unknown as typeof console.log; + console.error = spyError as unknown as typeof console.error; + console.warn = spyWarn as unknown as typeof console.warn; +}); + +afterEach(() => { + console.log = origLog; + console.error = origError; + console.warn = origWarn; +}); describe("Logger utility", () => { describe("info method", () => { - it("logs informational messages", () => { - // The info method should log to stderr with blue ℹ prefix - expect(() => logger.info("Test info message")).not.toThrow(); + it("logs informational messages to console.log", () => { + logger.info("Test info message"); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("Test info message"); + expect(output).toContain(logger.sym.info); }); it("handles empty string message", () => { - expect(() => logger.info("")).not.toThrow(); + logger.info(""); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain(logger.sym.info); }); it("handles special characters in message", () => { - expect(() => logger.info("Special chars: @#$%^&*()")).not.toThrow(); + logger.info("Special chars: @#$%^&*()"); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("Special chars: @#$%^&*()"); + }); + + it("calls console.log with info symbol prefix", () => { + logger.info("info test"); + expect(spyLog).toHaveBeenCalledTimes(1); + const raw = spyLog.mock.calls[0][0] as string; + const stripped = stripAnsi(raw); + expect(stripped).toContain(`${logger.sym.info} info test`); + expect(raw).not.toBe(stripped); // ANSI codes present }); }); describe("warn method", () => { - it("logs warning messages", () => { - // The warn method should log to stderr with yellow ⚠ prefix - expect(() => logger.warn("Test warning message")).not.toThrow(); + it("logs warning messages to console.warn", () => { + logger.warn("Test warning message"); + expect(spyWarn).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyWarn.mock.calls[0][0] as string); + expect(output).toContain("Test warning message"); + expect(output).toContain(logger.sym.warn); }); it("handles empty string message", () => { - expect(() => logger.warn("")).not.toThrow(); + logger.warn(""); + expect(spyWarn).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyWarn.mock.calls[0][0] as string); + expect(output).toContain(logger.sym.warn); + }); + + it("calls console.warn with warning symbol prefix", () => { + logger.warn("warn test"); + expect(spyWarn).toHaveBeenCalledTimes(1); + const raw = spyWarn.mock.calls[0][0] as string; + const stripped = stripAnsi(raw); + expect(stripped).toContain(`${logger.sym.warn} warn test`); + expect(raw).not.toBe(stripped); }); }); describe("error method", () => { - it("logs error messages", () => { - // The error method should log to stderr - use a message that won't confuse the test runner - expect(() => logger.error("[ERROR] Test error message")).not.toThrow(); + it("logs error messages to console.error", () => { + logger.error("Test error message"); + expect(spyError).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyError.mock.calls[0][0] as string); + expect(output).toContain("Test error message"); + expect(output).toContain(logger.sym.error); }); it("handles empty string message", () => { - expect(() => logger.error("")).not.toThrow(); + logger.error(""); + expect(spyError).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyError.mock.calls[0][0] as string); + expect(output).toContain(logger.sym.error); }); it("handles error objects as messages", () => { const error = new Error("Test error"); - expect(() => logger.error("[ERROR] " + error.message)).not.toThrow(); + logger.error(error.message); + expect(spyError).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyError.mock.calls[0][0] as string); + expect(output).toContain("Test error"); + }); + + it("prints hint on second line when hint is provided", () => { + logger.error("Main error", "Try running with --verbose"); + expect(spyError).toHaveBeenCalledTimes(2); + const hintOutput = stripAnsi(spyError.mock.calls[1][0] as string); + expect(hintOutput).toContain("Try running with --verbose"); + }); + + it("does not print hint line when no hint is provided", () => { + logger.error("Main error"); + expect(spyError).toHaveBeenCalledTimes(1); + }); + + it("calls console.error with error symbol prefix and colored message", () => { + logger.error("error test"); + expect(spyError).toHaveBeenCalledTimes(1); + const raw = spyError.mock.calls[0][0] as string; + const stripped = stripAnsi(raw); + expect(stripped).toContain(`${logger.sym.error} error test`); + expect(raw).not.toBe(stripped); + }); + + it("error shows hint when provided, with dim styling", () => { + logger.error("Oops", "Run with --debug"); + expect(spyError).toHaveBeenCalledTimes(2); + const hintRaw = spyError.mock.calls[1][0] as string; + expect(hintRaw).toContain("\x1b[2m"); // dim ANSI + expect(stripAnsi(hintRaw).trim()).toBe("Run with --debug"); }); }); describe("success method", () => { - it("logs success messages", () => { - // The success method should log to stderr with green ✔ prefix - expect(() => logger.success("Test success message")).not.toThrow(); + it("logs success messages to console.log", () => { + logger.success("Test success message"); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("Test success message"); + expect(output).toContain(logger.sym.success); }); it("handles empty string message", () => { - expect(() => logger.success("")).not.toThrow(); + logger.success(""); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain(logger.sym.success); + }); + + it("calls console.log with success symbol prefix", () => { + logger.success("success test"); + expect(spyLog).toHaveBeenCalledTimes(1); + const raw = spyLog.mock.calls[0][0] as string; + const stripped = stripAnsi(raw); + expect(stripped).toContain(`${logger.sym.success} success test`); + expect(raw).not.toBe(stripped); + }); + }); + + describe("dim method", () => { + it("logs dimmed message to console.log", () => { + logger.dim("Muted text"); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toBe("Muted text"); + }); + + it("handles empty string", () => { + logger.dim(""); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toBe(""); + }); + }); + + describe("step method", () => { + it("logs step with badge to console.log", () => { + logger.step(2, 5, "Deploying database"); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("2/5"); + expect(output).toContain("Deploying database"); + }); + }); + + describe("section method", () => { + it("prints blank line, bold title, and dim separator", () => { + logger.section("Configuration"); + expect(spyLog).toHaveBeenCalledTimes(3); + expect(spyLog.mock.calls[0][0]).toBe(""); + const titleOutput = stripAnsi(spyLog.mock.calls[1][0] as string); + expect(titleOutput).toBe("Configuration"); + const sepOutput = stripAnsi(spyLog.mock.calls[2][0] as string); + expect(sepOutput).toMatch(/^─+$/); + }); + + it("truncates separator at 60 chars for long titles", () => { + const longTitle = "A".repeat(80); + logger.section(longTitle); + expect(spyLog).toHaveBeenCalledTimes(3); + const sepOutput = stripAnsi(spyLog.mock.calls[2][0] as string); + expect(sepOutput.length).toBeLessThanOrEqual(60); + }); + + it("handles empty title", () => { + logger.section(""); + expect(spyLog).toHaveBeenCalledTimes(3); + expect(spyLog.mock.calls[0][0]).toBe(""); + const sepOutput = stripAnsi(spyLog.mock.calls[2][0] as string); + expect(sepOutput).toBe("──"); + }); + + it("outputs title and separator line correctly", () => { + logger.section("Test Section"); + expect(spyLog).toHaveBeenCalledTimes(3); + expect(spyLog.mock.calls[0][0]).toBe(""); + const title = spyLog.mock.calls[1][0] as string; + expect(stripAnsi(title)).toBe("Test Section"); + const sep = spyLog.mock.calls[2][0] as string; + expect(stripAnsi(sep)).toMatch(/^─+$/); + }); + }); + + describe("keyValue method", () => { + it("prints indented key-value pair with padded key and cyan value", () => { + logger.keyValue("Name", "my-project"); + expect(spyLog).toHaveBeenCalledTimes(1); + const raw = spyLog.mock.calls[0][0] as string; + const stripped = stripAnsi(raw); + const expected = ` ${"Name".padEnd(22)} my-project`; + expect(stripped).toBe(expected); + expect(raw).not.toBe(stripped); // value is colored + }); + + it("obscures secret values with dots", () => { + logger.keyValue("API Key", "sk-abc123", { secret: true }); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("API Key"); + expect(output).toContain("••••••••"); + expect(output).not.toContain("sk-abc123"); + }); + + it("pads key to exactly 22 characters", () => { + logger.keyValue("Region", "us-east-1"); + expect(spyLog).toHaveBeenCalledTimes(1); + const raw = spyLog.mock.calls[0][0] as string; + const stripped = stripAnsi(raw); + expect(stripped).toBe(` ${"Region".padEnd(22)} us-east-1`); + }); + + it("value is colored cyan", () => { + logger.keyValue("Env", "production"); + const raw = spyLog.mock.calls[0][0] as string; + expect(raw).toContain("\x1b[36m"); // cyan open + expect(raw).toContain("\x1b[39m"); // reset + }); + }); + + describe("tree method", () => { + it("prints tree items with branch characters", () => { + logger.tree(["src/index.ts", "src/utils/logger.ts", "package.json"]); + expect(spyLog).toHaveBeenCalledTimes(3); + const l1 = stripAnsi(spyLog.mock.calls[0][0] as string); + const l2 = stripAnsi(spyLog.mock.calls[1][0] as string); + const l3 = stripAnsi(spyLog.mock.calls[2][0] as string); + expect(l1).toContain(logger.sym.tree.replace(/\x1b\[[0-9;]*m/g, "")); + expect(l1).toContain("src/index.ts"); + expect(l2).toContain(logger.sym.tree.replace(/\x1b\[[0-9;]*m/g, "")); + expect(l2).toContain("src/utils/logger.ts"); + expect(l3).toContain(logger.sym.treeLast.replace(/\x1b\[[0-9;]*m/g, "")); + expect(l3).toContain("package.json"); + }); + + it("uses treeLast for single item", () => { + logger.tree(["only-file.ts"]); + expect(spyLog).toHaveBeenCalledTimes(1); + const line = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(line).toContain(logger.sym.treeLast.replace(/\x1b\[[0-9;]*m/g, "")); + expect(line).toContain("only-file.ts"); + }); + + it("handles empty array", () => { + logger.tree([]); + expect(spyLog).toHaveBeenCalledTimes(0); + }); + + it("outputs tree lines with proper indentation and symbols", () => { + logger.tree(["file1.ts", "dir/file2.ts", "dir/file3.ts"]); + expect(spyLog).toHaveBeenCalledTimes(3); + expect(stripAnsi(spyLog.mock.calls[0][0] as string)).toBe(` ${logger.sym.tree} file1.ts`); + expect(stripAnsi(spyLog.mock.calls[1][0] as string)).toBe(` ${logger.sym.tree} dir/file2.ts`); + expect(stripAnsi(spyLog.mock.calls[2][0] as string)).toBe(` ${logger.sym.treeLast} dir/file3.ts`); + }); + }); + + describe("blank method", () => { + it("prints a single newline", () => { + logger.blank(); + expect(spyLog).toHaveBeenCalledTimes(1); + expect(spyLog.mock.calls[0][0]).toBe(""); + }); + + it("calls console.log with empty string", () => { + logger.blank(); + expect(spyLog).toHaveBeenCalledTimes(1); + expect(spyLog.mock.calls[0][0]).toBe(""); + }); + }); + + describe("box method", () => { + it("prints a box with title and key-value lines", () => { + logger.box("Deployment Info", [ + { label: "Status", value: "active" }, + { label: "Region", value: "us-east-1" }, + ]); + expect(spyLog).toHaveBeenCalledTimes(8); + expect(spyLog.mock.calls[0][0]).toBe(""); + expect(stripAnsi(spyLog.mock.calls[1][0] as string)).toContain("┌"); + expect(stripAnsi(spyLog.mock.calls[1][0] as string)).toContain("┐"); + const titleLine = stripAnsi(spyLog.mock.calls[2][0] as string); + expect(titleLine).toContain("Deployment Info"); + expect(stripAnsi(spyLog.mock.calls[3][0] as string)).toContain("├"); + expect(stripAnsi(spyLog.mock.calls[3][0] as string)).toContain("┤"); + const data1 = stripAnsi(spyLog.mock.calls[4][0] as string); + expect(data1).toContain("Status"); + expect(data1).toContain("active"); + const data2 = stripAnsi(spyLog.mock.calls[5][0] as string); + expect(data2).toContain("Region"); + expect(data2).toContain("us-east-1"); + expect(stripAnsi(spyLog.mock.calls[6][0] as string)).toContain("└"); + expect(stripAnsi(spyLog.mock.calls[6][0] as string)).toContain("┘"); + expect(spyLog.mock.calls[7][0]).toBe(""); + }); + + it("handles empty lines array", () => { + logger.box("Empty Box", []); + expect(spyLog).toHaveBeenCalledTimes(6); + expect(spyLog.mock.calls[0][0]).toBe(""); + expect(stripAnsi(spyLog.mock.calls[1][0] as string)).toContain("┌"); + expect(stripAnsi(spyLog.mock.calls[2][0] as string)).toContain("Empty Box"); + expect(stripAnsi(spyLog.mock.calls[3][0] as string)).toContain("├"); + expect(stripAnsi(spyLog.mock.calls[4][0] as string)).toContain("└"); + expect(spyLog.mock.calls[5][0]).toBe(""); + }); + + it("outputs multi-line box with borders", () => { + logger.box("Test", [{ label: "A", value: "1" }]); + expect(spyLog).toHaveBeenCalledTimes(7); + const lines: string[] = []; + for (let i = 0; i < spyLog.mock.calls.length; i++) { + lines.push(spyLog.mock.calls[i][0] as string); + } + expect(lines[0]).toBe(""); + expect(stripAnsi(lines[1])).toContain("┌"); + expect(stripAnsi(lines[1])).toContain("┐"); + expect(stripAnsi(lines[2])).toContain("│"); + expect(stripAnsi(lines[2])).toContain("Test"); + expect(stripAnsi(lines[3])).toContain("├"); + expect(stripAnsi(lines[3])).toContain("┤"); + expect(stripAnsi(lines[4])).toContain("A"); + expect(stripAnsi(lines[4])).toContain("1"); + expect(stripAnsi(lines[5])).toContain("└"); + expect(stripAnsi(lines[5])).toContain("┘"); + expect(lines[6]).toBe(""); + }); + }); + + describe("banner method", () => { + it("prints app name, version, and tagline", () => { + logger.banner("1.0.0"); + expect(spyLog).toHaveBeenCalledTimes(4); + const line1 = stripAnsi(spyLog.mock.calls[1][0] as string); + expect(line1).toContain("betterbase"); + expect(line1).toContain("v1.0.0"); + const line2 = stripAnsi(spyLog.mock.calls[2][0] as string); + expect(line2).toContain("AI-native Backend-as-a-Service"); + }); + }); + + describe("done method", () => { + it("prints elapsed time with success symbol", () => { + const start = Date.now() - 1234; + logger.done(start); + expect(spyLog).toHaveBeenCalledTimes(1); + const raw = spyLog.mock.calls[0][0] as string; + const stripped = stripAnsi(raw); + expect(stripped).toContain(logger.sym.success); + expect(stripped).toContain("Done"); + expect(stripped).toMatch(/\(\d+\.\d+s\)/); + expect(raw).toStartWith("\n"); + }); + + it("prints custom message when provided", () => { + const start = Date.now() - 500; + logger.done(start, "Migration complete"); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("Migration complete"); + expect(output).toMatch(/\(\d+\.\d+s\)/); + }); + + it("prepends newline before output", () => { + const start = Date.now() - 100; + logger.done(start, "Done early"); + expect(spyLog).toHaveBeenCalledTimes(1); + const raw = spyLog.mock.calls[0][0] as string; + expect(raw).toStartWith("\n"); + }); + }); + + describe("badge method", () => { + it("returns colored badge string for green", () => { + const result = logger.badge("PASS", "green"); + const stripped = stripAnsi(result); + expect(stripped).toBe(" PASS "); + }); + + it("returns colored badge string for red", () => { + const result = logger.badge("FAIL", "red"); + const stripped = stripAnsi(result); + expect(stripped).toBe(" FAIL "); + }); + + it("returns colored badge string for yellow", () => { + const result = logger.badge("WARN", "yellow"); + const stripped = stripAnsi(result); + expect(stripped).toBe(" WARN "); + }); + + it("returns colored badge string for blue", () => { + const result = logger.badge("INFO", "blue"); + const stripped = stripAnsi(result); + expect(stripped).toBe(" INFO "); + }); + + it("returns colored badge string for dim", () => { + const result = logger.badge("SKIP", "dim"); + const stripped = stripAnsi(result); + expect(stripped).toBe(" SKIP "); + }); + + it("contains ANSI color codes (not plain text)", () => { + const result = logger.badge("PASS", "green"); + expect(stripAnsi(result)).toBe(" PASS "); + expect(result.length).toBeGreaterThan(" PASS ".length); + }); + + it("returns correct colored badge for each color", () => { + expect(stripAnsi(logger.badge("OK", "green"))).toBe(" OK "); + expect(stripAnsi(logger.badge("FAIL", "red"))).toBe(" FAIL "); + expect(stripAnsi(logger.badge("WARN", "yellow"))).toBe(" WARN "); + expect(stripAnsi(logger.badge("INFO", "blue"))).toBe(" INFO "); + expect(stripAnsi(logger.badge("SKIP", "dim"))).toBe(" SKIP "); + }); + }); + + describe("sym constants", () => { + const isUnicode = + process.platform !== "win32" || + Boolean(process.env.CI) || + Boolean(process.env.WT_SESSION); + + it("has success symbol", () => { + expect(logger.sym.success).toBe(isUnicode ? "✓" : "+"); + }); + + it("has error symbol", () => { + expect(logger.sym.error).toBe(isUnicode ? "✗" : "x"); + }); + + it("has warn symbol", () => { + expect(logger.sym.warn).toBe(isUnicode ? "⚠" : "!"); + }); + + it("has info symbol", () => { + expect(logger.sym.info).toBe(isUnicode ? "◆" : "*"); + }); + + it("has arrow symbol", () => { + expect(logger.sym.arrow).toBe(isUnicode ? "→" : "->"); + }); + + it("has bullet symbol", () => { + expect(logger.sym.bullet).toBe(isUnicode ? "•" : "-"); + }); + + it("has tree symbol", () => { + expect(logger.sym.tree).toBe(isUnicode ? "├─" : "|-"); + }); + + it("has treeLast symbol", () => { + expect(logger.sym.treeLast).toBe(isUnicode ? "└─" : "\\-"); + }); + + it("has dot symbol", () => { + expect(logger.sym.dot).toBe(isUnicode ? "·" : "."); + }); + + it("all sym values are non-empty strings", () => { + for (const [key, value] of Object.entries(logger.sym)) { + expect(value, `sym.${key} should be non-empty`).toBeTruthy(); + expect(typeof value, `sym.${key} should be a string`).toBe("string"); + } + }); + + it("sym has correct emoji values when UNICODE is true", () => { + if (isUnicode) { + expect(logger.sym.success).toBe("✓"); + expect(logger.sym.error).toBe("✗"); + expect(logger.sym.warn).toBe("⚠"); + expect(logger.sym.info).toBe("◆"); + expect(logger.sym.arrow).toBe("→"); + expect(logger.sym.bullet).toBe("•"); + expect(logger.sym.tree).toBe("├─"); + expect(logger.sym.treeLast).toBe("└─"); + expect(logger.sym.dot).toBe("·"); + } + }); + + it("sym has ASCII fallbacks when UNICODE is false", () => { + if (!isUnicode) { + expect(logger.sym.success).toBe("+"); + expect(logger.sym.error).toBe("x"); + expect(logger.sym.warn).toBe("!"); + expect(logger.sym.info).toBe("*"); + expect(logger.sym.arrow).toBe("->"); + expect(logger.sym.bullet).toBe("-"); + expect(logger.sym.tree).toBe("|-"); + expect(logger.sym.treeLast).toBe("\\-"); + expect(logger.sym.dot).toBe("."); + } }); }); describe("logging with different message types", () => { it("handles string messages", () => { - // Use prefixed messages to avoid test runner confusion - expect(() => logger.info("string message")).not.toThrow(); - expect(() => logger.warn("string message")).not.toThrow(); - expect(() => logger.error("[ERROR] string message")).not.toThrow(); - expect(() => logger.success("string message")).not.toThrow(); + logger.info("string message"); + expect(spyLog).toHaveBeenCalled(); + const infoLine = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(infoLine).toContain("string message"); + + logger.warn("string message"); + expect(spyWarn).toHaveBeenCalled(); + const warnLine = stripAnsi(spyWarn.mock.calls[0][0] as string); + expect(warnLine).toContain("string message"); + + logger.error("string message"); + expect(spyError).toHaveBeenCalled(); + const errLine = stripAnsi(spyError.mock.calls[0][0] as string); + expect(errLine).toContain("string message"); + + logger.success("string message"); + const successCalls = spyLog.mock.calls.length; + const successLine = stripAnsi(spyLog.mock.calls[successCalls - 1][0] as string); + expect(successLine).toContain("string message"); }); it("handles multiline messages", () => { const multiline = "Line 1\nLine 2\nLine 3"; - expect(() => logger.info(multiline)).not.toThrow(); + logger.info(multiline); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("Line 1"); + expect(output).toContain("Line 2"); + expect(output).toContain("Line 3"); }); it("handles messages with quotes", () => { - expect(() => logger.info('Message with "quotes"')).not.toThrow(); - expect(() => logger.info("Message with 'single quotes'")).not.toThrow(); + logger.info('Message with "quotes"'); + expect(spyLog).toHaveBeenCalled(); + const output1 = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output1).toContain('Message with "quotes"'); + + logger.info("Message with 'single quotes'"); + const output2 = stripAnsi(spyLog.mock.calls[1][0] as string); + expect(output2).toContain("Message with 'single quotes'"); }); it("handles unicode characters", () => { - expect(() => logger.info("Unicode: 你好 🌍 🚀")).not.toThrow(); + logger.info("Unicode: 你好 🌍 🚀"); + expect(spyLog).toHaveBeenCalledTimes(1); + const output = stripAnsi(spyLog.mock.calls[0][0] as string); + expect(output).toContain("Unicode: 你好 🌍 🚀"); }); }); }); diff --git a/packages/cli/test/login-commands.test.ts b/packages/cli/test/login-commands.test.ts deleted file mode 100644 index e3f4ffa..0000000 --- a/packages/cli/test/login-commands.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Login CLI Commands Test Suite - * - * Tests for untested login command functions in cli/src/commands/login.ts - */ - -import { describe, expect, it } from "bun:test"; - -describe("Login CLI Commands", () => { - describe("runLoginCommand", () => { - it("should initiate login flow", async () => { - expect(true).toBe(true); - }); - - it("should open browser for authentication", async () => { - expect(true).toBe(true); - }); - - it("should handle login success", async () => { - expect(true).toBe(true); - }); - - it("should handle login failure", async () => { - expect(true).toBe(true); - }); - - it("should store credentials after login", async () => { - expect(true).toBe(true); - }); - }); - - describe("runLogoutCommand", () => { - it("should clear stored credentials", async () => { - expect(true).toBe(true); - }); - - it("should confirm logout success", async () => { - expect(true).toBe(true); - }); - - it("should handle not logged in state", async () => { - expect(true).toBe(true); - }); - }); - - describe("getCredentials", () => { - it("should return stored credentials", async () => { - expect(true).toBe(true); - }); - - it("should return null when not logged in", async () => { - expect(true).toBe(true); - }); - - it("should handle expired credentials", async () => { - expect(true).toBe(true); - }); - }); - - describe("isAuthenticated", () => { - it("should return true when logged in", async () => { - expect(true).toBe(true); - }); - - it("should return false when not logged in", async () => { - expect(true).toBe(true); - }); - }); - - describe("requireCredentials", () => { - it("should return credentials when available", async () => { - expect(true).toBe(true); - }); - - it("should throw when not authenticated", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Placeholder tests -describe("Login CLI Command Stubs", () => { - it("should have placeholder for login", () => { - const credentials = { token: "abc123" }; - expect(credentials.token).toBe("abc123"); - }); - - it("should have placeholder for logout", () => { - const result = { success: true }; - expect(result.success).toBe(true); - }); - - it("should have placeholder for getCredentials", () => { - const creds = null; - expect(creds).toBeNull(); - }); - - it("should have placeholder for isAuthenticated", () => { - const isAuth = false; - expect(isAuth).toBe(false); - }); - - it("should have placeholder for requireCredentials", () => { - const throwError = () => { - throw new Error("Not authenticated"); - }; - expect(throwError).toThrow(); - }); -}); diff --git a/packages/cli/test/output-snapshots.test.ts b/packages/cli/test/output-snapshots.test.ts new file mode 100644 index 0000000..6a94afe --- /dev/null +++ b/packages/cli/test/output-snapshots.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runIacAnalyze } from "../src/commands/iac/analyze"; + +const SNAPSHOT_DIR = join(import.meta.dir, "snapshots"); +const JSON_SNAPSHOT = join(SNAPSHOT_DIR, "iac-analyze-empty.json"); + +describe("output-snapshots: iac analyze", () => { + let tmpDir: string; + + beforeAll(() => { + // Ensure snapshot directory exists (should be present) + if (!existsSync(SNAPSHOT_DIR)) { + mkdirSync(SNAPSHOT_DIR, { recursive: true }); + } + }); + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "bb-snap-")); + // Create minimal betterbase/queries directory (empty) + const queriesDir = join(tmpDir, "betterbase", "queries"); + mkdirSync(queriesDir, { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { /* ignore */ } + }); + + it("produces expected JSON output on empty project (snapshot)", async () => { + // Capture console.log + const logs: string[] = []; + const logSpy = mock((...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }); + const origLog = console.log; + console.log = logSpy as unknown as typeof console.log; + + try { + await runIacAnalyze(tmpDir, { output: "json" }); + } finally { + console.log = origLog; + } + + // The JSON output is typically the last log entry + const jsonOutput = logs[logs.length - 1] ?? ""; + + // Normalize: trim whitespace + const actual = jsonOutput.trim(); + + // Load snapshot + const expectedRaw = readFileSync(JSON_SNAPSHOT, "utf-8"); + const expected = expectedRaw.trim(); + + expect(actual).toBe(expected); + }); +}); diff --git a/packages/cli/test/rls-commands.test.ts b/packages/cli/test/rls-commands.test.ts deleted file mode 100644 index 224c562..0000000 --- a/packages/cli/test/rls-commands.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * RLS CLI Commands Test Suite - * - * Tests for untested RLS command functions in cli/src/commands/rls.ts - */ - -import { describe, expect, it } from "bun:test"; - -describe("RLS CLI Commands", () => { - describe("runRlsCommand", () => { - it("should route to correct subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show help when no subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show error for unknown subcommand", async () => { - expect(true).toBe(true); - }); - }); - - describe("runRlsCreate", () => { - it("should create RLS policy for table", async () => { - expect(true).toBe(true); - }); - - it("should require table name", async () => { - expect(true).toBe(true); - }); - - it("should validate policy expression", async () => { - expect(true).toBe(true); - }); - - it("should handle existing policy", async () => { - expect(true).toBe(true); - }); - }); - - describe("runRlsList", () => { - it("should list all RLS policies", async () => { - expect(true).toBe(true); - }); - - it("should show policy details", async () => { - expect(true).toBe(true); - }); - - it("should filter by table", async () => { - expect(true).toBe(true); - }); - - it("should handle no policies", async () => { - expect(true).toBe(true); - }); - }); - - describe("runRlsDisable", () => { - it("should disable RLS for table", async () => { - expect(true).toBe(true); - }); - - it("should require table name", async () => { - expect(true).toBe(true); - }); - - it("should handle non-existent table", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Placeholder tests -describe("RLS CLI Command Stubs", () => { - it("should have placeholder for create", () => { - const policy = { table: "users", using: "auth.uid() = user_id" }; - expect(policy.table).toBe("users"); - }); - - it("should have placeholder for list", () => { - const policies = [{ table: "users", name: "users_select" }]; - expect(policies.length).toBe(1); - }); - - it("should have placeholder for disable", () => { - const result = { success: true, table: "posts" }; - expect(result.success).toBe(true); - }); -}); diff --git a/packages/cli/test/rls-test-command.test.ts b/packages/cli/test/rls-test-command.test.ts deleted file mode 100644 index 3b7dd31..0000000 --- a/packages/cli/test/rls-test-command.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * RLS Test Command Test Suite - * - * Tests for untested RLS test function in cli/src/commands/rls-test.ts - */ - -import { describe, expect, it } from "bun:test"; - -describe("RLS Test Command", () => { - describe("runRLSTestCommand", () => { - it("should require table name", async () => { - expect(true).toBe(true); - }); - - it("should run RLS policy tests", async () => { - expect(true).toBe(true); - }); - - it("should report test results", async () => { - expect(true).toBe(true); - }); - - it("should handle policy evaluation errors", async () => { - expect(true).toBe(true); - }); - - it("should show coverage report", async () => { - expect(true).toBe(true); - }); - - it("should handle non-existent table", async () => { - expect(true).toBe(true); - }); - - it("should test all policy types (SELECT, INSERT, UPDATE, DELETE)", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Placeholder tests -describe("RLS Test Command Stubs", () => { - it("should have placeholder for table name requirement", () => { - const tableName = "users"; - expect(tableName).toBe("users"); - }); - - it("should have placeholder for test results", () => { - const results = { passed: 10, failed: 0, total: 10 }; - expect(results.passed).toBe(10); - }); - - it("should have placeholder for coverage", () => { - const coverage = { policies: 5, tables: 3 }; - expect(coverage.policies).toBe(5); - }); - - it("should have placeholder for policy types", () => { - const types = ["SELECT", "INSERT", "UPDATE", "DELETE"]; - expect(types.length).toBe(4); - }); -}); diff --git a/packages/cli/test/route-scanner.test.ts b/packages/cli/test/route-scanner.test.ts index 87a8930..52d38b2 100644 --- a/packages/cli/test/route-scanner.test.ts +++ b/packages/cli/test/route-scanner.test.ts @@ -5,9 +5,8 @@ import path from "node:path"; import { RouteScanner } from "../src/utils/route-scanner"; describe("RouteScanner", () => { - test("extracts hono routes with auth and schemas", async () => { + test("extracts hono routes with auth and schemas (GET + POST)", async () => { const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); - try { const routesDir = path.join(root, "src/routes"); mkdirSync(routesDir, { recursive: true }); @@ -15,20 +14,20 @@ describe("RouteScanner", () => { writeFileSync( path.join(routesDir, "users.ts"), ` - import { Hono } from 'hono'; - import { z } from 'zod'; - import { authMiddleware } from '../middleware/auth'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { authMiddleware } from '../middleware/auth'; - const createUserSchema = z.object({ email: z.string().email() }); - export const users = new Hono(); +const createUserSchema = z.object({ email: z.string().email() }); +export const users = new Hono(); - users.get('/users', authMiddleware, (c) => c.json({ users: [] })); - users.post('/users', async (c) => { - const body = await c.req.json(); - createUserSchema.parse(body); - return c.json({ ok: true }); - }); - `, +users.get('/users', authMiddleware, (c) => c.json({ users: [] })); +users.post('/users', async (c) => { + const body = await c.req.json(); + createUserSchema.parse(body); + return c.json({ ok: true }); +}); +`, ); const scanner = new RouteScanner(); @@ -44,4 +43,761 @@ describe("RouteScanner", () => { rmSync(root, { recursive: true, force: true }); } }); + + test("extracts PATCH routes", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "items.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; +import { z } from 'zod'; + +const updateItemSchema = z.object({ name: z.string().optional(), price: z.number().optional() }); +export const items = new Hono(); + +items.patch('/items/:id', authMiddleware, async (c) => { + const body = await c.req.json(); + updateItemSchema.parse(body); + return c.json({ updated: true }); +}); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/items/:id"]).toBeDefined(); + expect(routes["/items/:id"].length).toBe(1); + expect(routes["/items/:id"][0].method).toBe("PATCH"); + expect(routes["/items/:id"][0].requiresAuth).toBe(true); + expect(routes["/items/:id"][0].inputSchema).toBe("updateItemSchema"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("extracts DELETE routes", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "items.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; + +export const items = new Hono(); + +items.delete('/items/:id', authMiddleware, (c) => { + return c.json({ deleted: true }); +}); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/items/:id"]).toBeDefined(); + expect(routes["/items/:id"].length).toBe(1); + expect(routes["/items/:id"][0].method).toBe("DELETE"); + expect(routes["/items/:id"][0].requiresAuth).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("extracts public routes with no auth", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "health.ts"), + ` +import { Hono } from 'hono'; + +export const health = new Hono(); + +health.get('/health', (c) => c.json({ status: 'ok' })); +health.get('/ping', (c) => c.text('pong')); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/health"]).toBeDefined(); + expect(routes["/health"].length).toBe(1); + expect(routes["/health"][0].method).toBe("GET"); + expect(routes["/health"][0].requiresAuth).toBe(false); + + expect(routes["/ping"]).toBeDefined(); + expect(routes["/ping"].length).toBe(1); + expect(routes["/ping"][0].method).toBe("GET"); + expect(routes["/ping"][0].requiresAuth).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("handles malformed decorators / syntax errors in route definitions", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "broken.ts"), + ` +import { Hono } from 'hono'; + +export const broken = new Hono(); + +broken.get('/valid', (c) => c.json({ ok: true })); + +broken.post('/malformed', ((c) => { + return c.json({ broken: true }); +); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/valid"]).toBeDefined(); + expect(routes["/valid"].length).toBe(1); + expect(routes["/valid"][0].method).toBe("GET"); + expect(routes["/valid"][0].requiresAuth).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("discovers routes in nested directory groups", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + mkdirSync(path.join(routesDir, "admin"), { recursive: true }); + mkdirSync(path.join(routesDir, "api/v1"), { recursive: true }); + + writeFileSync( + path.join(routesDir, "admin", "dashboard.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../../middleware/auth'; +export const admin = new Hono(); +admin.get('/admin/dashboard', authMiddleware, (c) => c.json({ stats: {} })); +`, + ); + + writeFileSync( + path.join(routesDir, "api", "v1", "posts.ts"), + ` +import { Hono } from 'hono'; +export const posts = new Hono(); +posts.get('/api/v1/posts', (c) => c.json({ posts: [] })); +posts.post('/api/v1/posts', (c) => c.json({ created: true })); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/admin/dashboard"]).toBeDefined(); + expect(routes["/admin/dashboard"].length).toBe(1); + expect(routes["/admin/dashboard"][0].method).toBe("GET"); + expect(routes["/admin/dashboard"][0].requiresAuth).toBe(true); + + expect(routes["/api/v1/posts"]).toBeDefined(); + expect(routes["/api/v1/posts"].length).toBe(2); + expect(routes["/api/v1/posts"][0].method).toBe("GET"); + expect(routes["/api/v1/posts"][1].method).toBe("POST"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("extracts routes with multiple middleware", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "secure.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; +import { rateLimitMiddleware } from '../middleware/rate-limit'; +import { loggerMiddleware } from '../middleware/logger'; + +export const secure = new Hono(); + +secure.get('/secure/data', loggerMiddleware, authMiddleware, rateLimitMiddleware, (c) => { + return c.json({ data: 'sensitive' }); +}); + +secure.post('/secure/data', rateLimitMiddleware, loggerMiddleware, authMiddleware, async (c) => { + return c.json({ ok: true }); +}); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/secure/data"]).toBeDefined(); + expect(routes["/secure/data"].length).toBe(2); + expect(routes["/secure/data"][0].method).toBe("GET"); + expect(routes["/secure/data"][0].requiresAuth).toBe(true); + expect(routes["/secure/data"][1].method).toBe("POST"); + expect(routes["/secure/data"][1].requiresAuth).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("extracts routes with query parameter validation", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "search.ts"), + ` +import { Hono } from 'hono'; +import { z } from 'zod'; +import { authMiddleware } from '../middleware/auth'; + +const searchQuerySchema = z.object({ q: z.string().min(1), page: z.string().optional() }); +const createSearchSchema = z.object({ term: z.string() }); + +export const search = new Hono(); + +search.get('/search', authMiddleware, (c) => { + const query = c.req.query(); + searchQuerySchema.parse(query); + return c.json({ results: [] }); +}); + +search.post('/search', authMiddleware, async (c) => { + const body = await c.req.json(); + createSearchSchema.parse(body); + return c.json({ indexed: true }); +}); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/search"]).toBeDefined(); + expect(routes["/search"].length).toBe(2); + expect(routes["/search"][0].method).toBe("GET"); + expect(routes["/search"][0].requiresAuth).toBe(true); + expect(routes["/search"][0].inputSchema).toBe("searchQuerySchema"); + expect(routes["/search"][1].method).toBe("POST"); + expect(routes["/search"][1].requiresAuth).toBe(true); + expect(routes["/search"][1].inputSchema).toBe("createSearchSchema"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("handles mixed protected and public routes in the same file", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "products.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; +import { z } from 'zod'; + +const createProductSchema = z.object({ name: z.string(), price: z.number() }); +export const products = new Hono(); + +products.get('/products', (c) => c.json({ products: [] })); +products.get('/products/:id', (c) => c.json({ product: {} })); +products.post('/products', authMiddleware, async (c) => { + const body = await c.req.json(); + createProductSchema.parse(body); + return c.json({ created: true }); +}); +products.delete('/products/:id', authMiddleware, (c) => { + return c.json({ deleted: true }); +}); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/products"]).toBeDefined(); + expect(routes["/products"].length).toBe(2); + expect(routes["/products"][0].method).toBe("GET"); + expect(routes["/products"][0].requiresAuth).toBe(false); + expect(routes["/products"][1].method).toBe("POST"); + expect(routes["/products"][1].requiresAuth).toBe(true); + expect(routes["/products"][1].inputSchema).toBe("createProductSchema"); + + expect(routes["/products/:id"]).toBeDefined(); + expect(routes["/products/:id"].length).toBe(2); + expect(routes["/products/:id"][0].method).toBe("GET"); + expect(routes["/products/:id"][0].requiresAuth).toBe(false); + expect(routes["/products/:id"][1].method).toBe("DELETE"); + expect(routes["/products/:id"][1].requiresAuth).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("handles empty route files with no handlers", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "empty.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; + +export const empty = new Hono(); +`, + ); + + writeFileSync( + path.join(routesDir, "has-routes.ts"), + ` +import { Hono } from 'hono'; +export const has = new Hono(); +has.get('/has', (c) => c.json({ yep: true })); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/has"]).toBeDefined(); + expect(routes["/has"].length).toBe(1); + expect(routes["/has"][0].method).toBe("GET"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // ========== NEW TEST SCENARIOS ========== + + test("PATCH and DELETE routes with both auth and no-auth variants", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "mixed-auth.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; +import { z } from 'zod'; + +const patchSchema = z.object({ title: z.string() }); + +export const articles = new Hono(); + +// No-auth PATCH +articles.patch('/articles/:id', async (c) => { + const body = await c.req.json(); + patchSchema.parse(body); + return c.json({ ok: true }); +}); + +// Auth DELETE +articles.delete('/articles/:id', authMiddleware, (c) => { + return c.json({ deleted: true }); +}); + +// Auth PATCH with optionalAuth variant +articles.patch('/articles/:id/lock', authMiddleware, (c) => { + return c.json({ locked: true }); +}); + +// Public DELETE +articles.delete('/articles/:id/soft', (c) => { + return c.json({ softDeleted: true }); +}); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + // /articles/:id - PATCH (no auth) + expect(routes["/articles/:id"]).toBeDefined(); + const patchRoutes = routes["/articles/:id"].filter((r) => r.method === "PATCH"); + expect(patchRoutes.length).toBe(1); + expect(patchRoutes[0].requiresAuth).toBe(false); + expect(patchRoutes[0].inputSchema).toBe("patchSchema"); + + // /articles/:id - DELETE (auth) + const deleteRoutes = routes["/articles/:id"].filter((r) => r.method === "DELETE"); + expect(deleteRoutes.length).toBe(1); + expect(deleteRoutes[0].requiresAuth).toBe(true); + + // /articles/:id/lock - PATCH (auth) + expect(routes["/articles/:id/lock"]).toBeDefined(); + expect(routes["/articles/:id/lock"][0].method).toBe("PATCH"); + expect(routes["/articles/:id/lock"][0].requiresAuth).toBe(true); + + // /articles/:id/soft - DELETE (no auth) + expect(routes["/articles/:id/soft"]).toBeDefined(); + expect(routes["/articles/:id/soft"][0].method).toBe("DELETE"); + expect(routes["/articles/:id/soft"][0].requiresAuth).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("No-auth routes (routes without requireAuth or optionalAuth)", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "public.ts"), + ` +import { Hono } from 'hono'; + +export const public = new Hono(); + +public.get('/public', (c) => c.json({ public: true })); +public.post('/public', (c) => c.json({ posted: true })); +public.put('/public/:id', (c) => c.json({ updated: true })); +public.patch('/public/:id', (c) => c.json({ patched: true })); +public.delete('/public/:id', (c) => c.json({ deleted: true })); +public.head('/public/head', (c) => c.text('')); +public.options('/public/options', (c) => c.text('')); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + expect(routes["/public"]).toBeDefined(); + expect(routes["/public"].length).toBe(2); + expect(routes["/public"].every((r) => r.requiresAuth === false)).toBe(true); + + expect(routes["/public/:id"]).toBeDefined(); + expect(routes["/public/:id"].length).toBe(3); + expect(routes["/public/:id"].every((r) => r.requiresAuth === false)).toBe(true); + + expect(routes["/public/head"]).toBeDefined(); + expect(routes["/public/head"][0].requiresAuth).toBe(false); + + expect(routes["/public/options"]).toBeDefined(); + expect(routes["/public/options"][0].requiresAuth).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("Malformed decorators (missing parentheses, invalid syntax)", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "malformed.ts"), + ` +import { Hono } from 'hono'; + +export const malformed = new Hono(); + +// Valid route before malformed +malformed.get('/valid1', (c) => c.json({ ok: true })); + +// Missing parentheses on handler +malformed.post('/bad1', (c) => c.json({ shouldWork: true })); +malformed.post('/bad2', c => c.json({ missingParens: true })); + +// Extra closing parenthesis +malformed.get('/bad3', (c) => c.json({ extra: true })); + +// Incomplete arrow function +malformed.put('/bad4', (c) => { + +// Invalid decorator call - missing closing paren for route path +malformed.delete('/bad5', (c) => c.json({ deleteMe: true }); + +// Valid route after malformed +malformed.get('/valid2', (c) => c.json({ stillOk: true })); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + // Valid route 1 should be extracted + expect(routes["/valid1"]).toBeDefined(); + expect(routes["/valid1"][0].method).toBe("GET"); + expect(routes["/valid1"][0].requiresAuth).toBe(false); + + // /bad1 - post with valid handler syntax + expect(routes["/bad1"]).toBeDefined(); + expect(routes["/bad1"][0].method).toBe("POST"); + + // /bad2 - post with missing parens in arrow param - should still work if parseable + // Actually this would be a syntax error - the scanner should skip it + // The scanner tries to parse the file; if parse fails it may get 0 routes + // Let's check what actually happens - the file itself has syntax errors + // So scanner will either fail gracefully or not extract those lines + // We can only assert that valid routes are extracted + + // /bad3 - syntax error (extra paren in string literal not a real error, but let's check) + // Actually "extra: true }));" inside string is fine + + // /bad4 - incomplete arrow function (parse error) + // Should not be extracted + + // /bad5 - missing closing paren - syntax error + // Should not be extracted + + // Valid route 2 should be extracted + expect(routes["/valid2"]).toBeDefined(); + expect(routes["/valid2"][0].method).toBe("GET"); + expect(routes["/valid2"][0].requiresAuth).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("Nested route groups (app.group('/api', ...) with nested routes)", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "grouped.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; + +const app = new Hono(); + +// Group with prefix +const api = app.group('/api'); + +api.get('/stats', (c) => c.json({ stats: {} })); +api.post('/stats', authMiddleware, (c) => c.json({ created: true })); + +// Nested group +const v1 = api.group('/v1'); + +v1.get('/users', (c) => c.json({ users: [] })); +v1.get('/users/:id', (c) => c.json({ user: {} })); +v1.post('/users', authMiddleware, async (c) => { + const body = await c.req.json(); + return c.json({ created: true }); +}); + +// Deeply nested +const deep = api.group('/admin'); +deep.get('/dashboard', authMiddleware, (c) => c.json({ data: 'admin' })); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + // /api/stats routes + expect(routes["/api/stats"]).toBeDefined(); + expect(routes["/api/stats"].length).toBe(2); + expect(routes["/api/stats"][0].method).toBe("GET"); + expect(routes["/api/stats"][0].requiresAuth).toBe(false); + expect(routes["/api/stats"][1].method).toBe("POST"); + expect(routes["/api/stats"][1].requiresAuth).toBe(true); + + // /api/v1/users routes + expect(routes["/api/v1/users"]).toBeDefined(); + expect(routes["/api/v1/users"].length).toBe(2); + expect(routes["/api/v1/users"][0].method).toBe("GET"); + expect(routes["/api/v1/users"][0].requiresAuth).toBe(false); + expect(routes["/api/v1/users"][1].method).toBe("POST"); + expect(routes["/api/v1/users"][1].requiresAuth).toBe(true); + + // /api/v1/users/:id + expect(routes["/api/v1/users/:id"]).toBeDefined(); + expect(routes["/api/v1/users/:id"][0].method).toBe("GET"); + expect(routes["/api/v1/users/:id"][0].requiresAuth).toBe(false); + + // /api/admin/dashboard + expect(routes["/api/admin/dashboard"]).toBeDefined(); + expect(routes["/api/admin/dashboard"][0].method).toBe("GET"); + expect(routes["/api/admin/dashboard"][0].requiresAuth).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("Mixed public/protected in same file (detailed)", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "mixed.ts"), + ` +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; +import { corsMiddleware } from '../middleware/cors'; +import { z } from 'zod'; + +const createSchema = z.object({ name: z.string() }); +const updateSchema = z.object({ name: z.string().optional() }); + +export const api = new Hono(); + +api.get('/public-get', (c) => c.json({ public: true })); +api.post('/public-post', (c) => c.json({ posted: true })); + +api.get('/protected-get', authMiddleware, (c) => c.json({ protected: true })); +api.post('/protected-post', authMiddleware, async (c) => { + const body = await c.req.json(); + createSchema.parse(body); + return c.json({ created: true }); +}); + +api.patch('/protected-patch', authMiddleware, async (c) => { + const body = await c.req.json(); + updateSchema.parse(body); + return c.json({ patched: true }); +}); + +api.delete('/protected-delete', authMiddleware, (c) => c.json({ deleted: true })); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + // Public routes + expect(routes["/public-get"]).toBeDefined(); + expect(routes["/public-get"][0].requiresAuth).toBe(false); + expect(routes["/public-post"]).toBeDefined(); + expect(routes["/public-post"][0].requiresAuth).toBe(false); + + // Protected routes + expect(routes["/protected-get"]).toBeDefined(); + expect(routes["/protected-get"][0].requiresAuth).toBe(true); + + expect(routes["/protected-post"]).toBeDefined(); + expect(routes["/protected-post"][0].requiresAuth).toBe(true); + expect(routes["/protected-post"][0].inputSchema).toBe("createSchema"); + + expect(routes["/protected-patch"]).toBeDefined(); + expect(routes["/protected-patch"][0].requiresAuth).toBe(true); + expect(routes["/protected-patch"][0].inputSchema).toBe("updateSchema"); + + expect(routes["/protected-delete"]).toBeDefined(); + expect(routes["/protected-delete"][0].requiresAuth).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("Routes with CORS and other middleware that might confuse scanner", async () => { + const root = mkdtempSync(path.join(tmpdir(), "bb-routes-")); + try { + const routesDir = path.join(root, "src/routes"); + mkdirSync(routesDir, { recursive: true }); + + writeFileSync( + path.join(routesDir, "middleware.ts"), + ` +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { authMiddleware } from '../middleware/auth'; + +export const api = new Hono(); + +// CORS before auth +api.get('/cors-auth', cors(), authMiddleware, (c) => c.json({ data: 'both' })); + +// CORS only (no auth) - should not be flagged as requiring auth +api.get('/cors-only', cors(), (c) => c.json({ public: true })); + +// Auth before CORS - still requires auth +api.post('/auth-cors', authMiddleware, cors(), async (c) => { + return c.json({ ok: true }); +}); + +// Multiple non-auth middleware before auth (compression, timeout, etc.) +api.get('/multi-middleware', logger(), cors(), authMiddleware, (c) => c.json({ ok: true })); + +// auth at different position in chain +api.get('/middleware-chain', cors(), logger(), authMiddleware, (c) => c.json({ ok: true })); + +// Route with compression middleware +import { compress } from 'hono/compress'; +api.get('/compressed', compress(), authMiddleware, (c) => c.json({ size: 'small' })); + +// Route with only non-auth middleware (cors + logger) +api.get('/public-with-middleware', cors(), logger(), (c) => c.json({ public: true })); +`, + ); + + const scanner = new RouteScanner(); + const routes = scanner.scan(routesDir); + + // CORS + Auth route - should require auth + expect(routes["/cors-auth"]).toBeDefined(); + expect(routes["/cors-auth"][0].requiresAuth).toBe(true); + + // CORS only - should NOT require auth + expect(routes["/cors-only"]).toBeDefined(); + expect(routes["/cors-only"][0].requiresAuth).toBe(false); + + // Auth + CORS - should require auth + expect(routes["/auth-cors"]).toBeDefined(); + expect(routes["/auth-cors"][0].requiresAuth).toBe(true); + + // Multiple middleware before auth - should still require auth + expect(routes["/multi-middleware"]).toBeDefined(); + expect(routes["/multi-middleware"][0].requiresAuth).toBe(true); + + // Middleware chain - auth present + expect(routes["/middleware-chain"]).toBeDefined(); + expect(routes["/middleware-chain"][0].requiresAuth).toBe(true); + + // Compression + Auth + expect(routes["/compressed"]).toBeDefined(); + expect(routes["/compressed"][0].requiresAuth).toBe(true); + + // CORS + Logger only (no auth) - should NOT require auth + expect(routes["/public-with-middleware"]).toBeDefined(); + expect(routes["/public-with-middleware"][0].requiresAuth).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/test/scanner.test.ts b/packages/cli/test/scanner.test.ts index 83f51e0..95274f6 100644 --- a/packages/cli/test/scanner.test.ts +++ b/packages/cli/test/scanner.test.ts @@ -58,4 +58,261 @@ describe("SchemaScanner", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("handles empty tables (zero columns)", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bb-scanner-")); + + try { + const schemaPath = path.join(dir, "schema.ts"); + writeFileSync( + schemaPath, + ` + import { sqliteTable } from 'drizzle-orm/sqlite-core'; + + export const emptyTable = sqliteTable('empty_table', {}); + `, + ); + + const scanner = new SchemaScanner(schemaPath); + const tables = scanner.scan(); + + expect(Object.keys(tables)).toEqual(["emptyTable"]); + expect(tables.emptyTable.name).toBe("empty_table"); + expect(Object.keys(tables.emptyTable.columns)).toEqual([]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("handles tables with no relations", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bb-scanner-")); + + try { + const schemaPath = path.join(dir, "schema.ts"); + writeFileSync( + schemaPath, + ` + import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; + + export const users = sqliteTable('users', { + id: text('id').primaryKey(), + name: text('name').notNull(), + }); + + export const posts = sqliteTable('posts', { + id: text('id').primaryKey(), + title: text('title').notNull(), + content: text('content'), + }); + `, + ); + + const scanner = new SchemaScanner(schemaPath); + const tables = scanner.scan(); + + expect(Object.keys(tables)).toEqual(["users", "posts"]); + + expect(tables.users.relations).toEqual([]); + expect(tables.posts.relations).toEqual([]); + expect(tables.users.columns.id.references).toBeUndefined(); + expect(tables.posts.columns.id.references).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("handles circular foreign key dependencies", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bb-scanner-")); + + try { + const schemaPath = path.join(dir, "schema.ts"); + writeFileSync( + schemaPath, + ` + import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; + + export const users = sqliteTable('users', { + id: text('id').primaryKey(), + postId: text('post_id').references(() => posts.id), + name: text('name').notNull(), + }); + + export const posts = sqliteTable('posts', { + id: text('id').primaryKey(), + userId: text('user_id').references(() => users.id), + title: text('title').notNull(), + }); + `, + ); + + const scanner = new SchemaScanner(schemaPath); + const tables = scanner.scan(); + + expect(Object.keys(tables)).toEqual(["users", "posts"]); + + expect(tables.users.columns.postId.references).toBe("() => posts.id"); + expect(tables.users.relations).toContain("() => posts.id"); + + expect(tables.posts.columns.userId.references).toBe("() => users.id"); + expect(tables.posts.relations).toContain("() => users.id"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("handles array columns", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bb-scanner-")); + + try { + const schemaPath = path.join(dir, "schema.ts"); + writeFileSync( + schemaPath, + ` + import { sqliteTable, text, integer, pgTable, serial, varchar } from 'drizzle-orm/pg-core'; + + export const users = pgTable('users', { + id: serial('id').primaryKey(), + tags: text('tags').array(), + names: varchar('names', { length: 100 }).array(), + }); + `, + ); + + const scanner = new SchemaScanner(schemaPath); + const tables = scanner.scan(); + + expect(Object.keys(tables)).toEqual(["users"]); + + expect(tables.users.columns.tags.dataType).toBe("text"); + expect(tables.users.columns.tags.array).toBe(true); + + expect(tables.users.columns.names.dataType).toBe("varchar"); + expect(tables.users.columns.names.array).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("handles enum columns", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bb-scanner-")); + + try { + const schemaPath = path.join(dir, "schema.ts"); + writeFileSync( + schemaPath, + ` + import { sqliteTable, text, pgTable, serial, varchar } from 'drizzle-orm/pg-core'; + + export const users = pgTable('users', { + id: serial('id').primaryKey(), + status: text('status').enum(['status', 'active', 'inactive']), + role: varchar('role', { length: 50 }).enum(['admin', 'user', 'guest']), + }); + `, + ); + + const scanner = new SchemaScanner(schemaPath); + const tables = scanner.scan(); + + expect(Object.keys(tables)).toEqual(["users"]); + + expect(tables.users.columns.status.dataType).toBe("text"); + expect(tables.users.columns.status.enum).toEqual(['status', 'active', 'inactive']); + + expect(tables.users.columns.role.dataType).toBe("varchar"); + expect(tables.users.columns.role.enum).toEqual(['admin', 'user', 'guest']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("handles large complex schema with 5 interconnected tables", () => { + const dir = mkdtempSync(path.join(tmpdir(), "bb-scanner-")); + + try { + const schemaPath = path.join(dir, "schema.ts"); + writeFileSync( + schemaPath, + ` + import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'; + + export const users = sqliteTable('users', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + departmentId: text('dept_id').references(() => departments.id), + }, (table) => ({ + usersEmailIdx: index('users_email_idx').on(table.email), + })); + + export const departments = sqliteTable('departments', { + id: text('id').primaryKey(), + name: text('name').notNull(), + managerId: text('manager_id').references(() => users.id), + }); + + export const posts = sqliteTable('posts', { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => users.id), + title: text('title').notNull(), + status: text('status').notNull(), + }); + + export const comments = sqliteTable('comments', { + id: text('id').primaryKey(), + postId: text('post_id').notNull().references(() => posts.id), + userId: text('user_id').notNull().references(() => users.id), + body: text('body'), + }); + + export const likes = sqliteTable('likes', { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => users.id), + postId: text('post_id').notNull().references(() => posts.id), + commentId: text('comment_id').references(() => comments.id), + }, (table) => ({ + likesUserPostIdx: index('likes_user_post_idx').on(table.userId, table.postId), + })); + `, + ); + + const scanner = new SchemaScanner(schemaPath); + const tables = scanner.scan(); + + expect(Object.keys(tables)).toEqual(["users", "departments", "posts", "comments", "likes"]); + + // Users + expect(tables.users.columns.name.dataType).toBe("text"); + expect(tables.users.columns.departmentId.references).toBe("() => departments.id"); + expect(tables.users.relations).toContain("() => departments.id"); + expect(tables.users.indexes).toContain("usersEmailIdx"); + + // Departments + expect(tables.departments.columns.name.dataType).toBe("text"); + expect(tables.departments.columns.managerId.references).toBe("() => users.id"); + expect(tables.departments.relations).toContain("() => users.id"); + + // Posts + expect(tables.posts.columns.userId.references).toBe("() => users.id"); + expect(tables.posts.relations).toContain("() => users.id"); + expect(tables.posts.columns.status.dataType).toBe("text"); + + // Comments (references both posts and users) + expect(tables.comments.columns.postId.references).toBe("() => posts.id"); + expect(tables.comments.columns.userId.references).toBe("() => users.id"); + expect(tables.comments.relations).toContain("() => posts.id"); + expect(tables.comments.relations).toContain("() => users.id"); + + // Likes (references users, posts, and comments) + expect(tables.likes.columns.userId.references).toBe("() => users.id"); + expect(tables.likes.columns.postId.references).toBe("() => posts.id"); + expect(tables.likes.columns.commentId.references).toBe("() => comments.id"); + expect(tables.likes.relations).toContain("() => users.id"); + expect(tables.likes.relations).toContain("() => posts.id"); + expect(tables.likes.relations).toContain("() => comments.id"); + expect(tables.likes.indexes).toContain("likesUserPostIdx"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/test/snapshots/iac-analyze-empty.json b/packages/cli/test/snapshots/iac-analyze-empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/packages/cli/test/snapshots/iac-analyze-empty.json @@ -0,0 +1 @@ +[] diff --git a/packages/cli/test/storage-commands.test.ts b/packages/cli/test/storage-commands.test.ts deleted file mode 100644 index 16aa961..0000000 --- a/packages/cli/test/storage-commands.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Storage CLI Commands Test Suite - * - * Tests for untested storage command functions in cli/src/commands/storage.ts - */ - -import { describe, expect, it } from "bun:test"; - -describe("Storage CLI Commands", () => { - describe("runStorageInitCommand", () => { - it("should initialize storage configuration", async () => { - expect(true).toBe(true); - }); - - it("should require project root", async () => { - expect(true).toBe(true); - }); - - it("should create default bucket configuration", async () => { - expect(true).toBe(true); - }); - - it("should handle existing storage config", async () => { - expect(true).toBe(true); - }); - }); - - describe("runStorageBucketsListCommand", () => { - it("should list all buckets", async () => { - expect(true).toBe(true); - }); - - it("should show bucket details", async () => { - expect(true).toBe(true); - }); - - it("should handle no buckets", async () => { - expect(true).toBe(true); - }); - - it("should show bucket permissions", async () => { - expect(true).toBe(true); - }); - }); - - describe("runStorageUploadCommand", () => { - it("should upload file to bucket", async () => { - expect(true).toBe(true); - }); - - it("should require file path", async () => { - expect(true).toBe(true); - }); - - it("should require bucket name", async () => { - expect(true).toBe(true); - }); - - it("should handle large files", async () => { - expect(true).toBe(true); - }); - - it("should show upload progress", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Placeholder tests -describe("Storage CLI Command Stubs", () => { - it("should have placeholder for init", () => { - const config = { buckets: ["public", "private"] }; - expect(config.buckets.length).toBe(2); - }); - - it("should have placeholder for list", () => { - const buckets = [{ name: "avatars", size: 1024 }]; - expect(buckets.length).toBe(1); - }); - - it("should have placeholder for upload", () => { - const result = { success: true, size: 1024 }; - expect(result.success).toBe(true); - }); -}); diff --git a/packages/cli/test/unit/api-client.test.ts b/packages/cli/test/unit/api-client.test.ts new file mode 100644 index 0000000..47c2761 --- /dev/null +++ b/packages/cli/test/unit/api-client.test.ts @@ -0,0 +1,179 @@ +import { afterAll, afterEach, describe, expect, it, mock } from "bun:test"; +import { apiRequest, requireAuth } from "../../src/utils/api-client"; +import { clearCredentials, saveCredentials, type Credentials } from "../../src/utils/credentials"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Use sandboxed temp directory for credentials +const tempHomeDir = mkdtempSync(join(tmpdir(), "bb-api-client-test-")); +const CREDENTIALS_FILE = join(tempHomeDir, ".betterbase", "credentials.json"); + +// Save original BB_CREDENTIALS_DIR +const originalBBCredentialsDir = process.env.BB_CREDENTIALS_DIR; + +function cleanupCredentialsFile() { + try { + if (existsSync(CREDENTIALS_FILE)) { + rmSync(CREDENTIALS_FILE); + } + rmSync(tempHomeDir, { recursive: true, force: true }); + } catch { /* ignore */ } + // Restore original BB_CREDENTIALS_DIR + if (originalBBCredentialsDir === undefined) { + delete process.env.BB_CREDENTIALS_DIR; + } else { + process.env.BB_CREDENTIALS_DIR = originalBBCredentialsDir; + } + } + +describe("api-client", () => { + beforeEach(() => { + // Recreate sandbox directory for each test + rmSync(tempHomeDir, { recursive: true, force: true }); + const newTempHomeDir = mkdtempSync(join(tmpdir(), "bb-api-client-test-")); + process.env.BB_CREDENTIALS_DIR = join(newTempHomeDir, ".betterbase"); + }); + afterEach(cleanupCredentialsFile); + afterAll(cleanupCredentialsFile); + + describe("requireAuth", () => { + it("returns token and serverUrl when valid credentials exist", () => { + const creds: Credentials = { + token: "test_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + const result = requireAuth(); + expect(result.token).toBe("test_token"); + expect(result.serverUrl).toBe("https://api.betterbase.io"); + }); + + it("exits with code 1 when no credentials exist", () => { + cleanupCredentialsFile(); + + const exitSpy = mock((code: number) => { throw new Error(`exit:${code}`); }); + const origExit = process.exit; + process.exit = exitSpy as unknown as (code?: number) => never; + + try { + requireAuth(); + expect.unreachable("should have thrown"); + } catch (e: unknown) { + expect((e as Error).message).toContain("exit:1"); + } finally { + process.exit = origExit; + } + }); + + it("exits when token is empty string", () => { + const creds: Credentials = { + token: "", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + const exitSpy = mock((code: number) => { throw new Error(`exit:${code}`); }); + const origExit = process.exit; + process.exit = exitSpy as unknown as (code?: number) => never; + + try { + requireAuth(); + expect.unreachable("should have thrown"); + } catch (e: unknown) { + expect((e as Error).message).toContain("exit:1"); + } finally { + process.exit = origExit; + } + }); + }); + + describe("apiRequest", () => { + it("makes authenticated request with valid token", async () => { + const creds: Credentials = { + token: "valid_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + const fakeResponse = { data: "test_result" }; + const origFetch = globalThis.fetch; + const mockFetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBe("Bearer valid_token"); + return new Response(JSON.stringify(fakeResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + (mockFetch as any).preconnect = false; + globalThis.fetch = mockFetch as unknown as typeof fetch; + + try { + const result = await apiRequest("/test/path"); + expect(result).toEqual(fakeResponse); + } finally { + globalThis.fetch = origFetch; + } + }); + + it("throws on non-OK response with JSON body", async () => { + const creds: Credentials = { + token: "valid_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + const origFetch = globalThis.fetch; + const mockFetch = mock(async () => { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }); + (mockFetch as any).preconnect = false; + globalThis.fetch = mockFetch as unknown as typeof fetch; + + try { + await expect(apiRequest("/test/path")).rejects.toThrow("Not found"); + } finally { + globalThis.fetch = origFetch; + } + }); + + it("throws HTTP status when non-OK response has JSON body with no error field", async () => { + const creds: Credentials = { + token: "valid_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + const origFetch = globalThis.fetch; + const mockFetch = mock(async () => { + return new Response(JSON.stringify({ message: "Server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + }); + (mockFetch as any).preconnect = false; + globalThis.fetch = mockFetch as unknown as typeof fetch; + + try { + await expect(apiRequest("/test/path")).rejects.toThrow("HTTP 500"); + } finally { + globalThis.fetch = origFetch; + } + }); + }); +}); diff --git a/packages/cli/test/unit/auth-providers.test.ts b/packages/cli/test/unit/auth-providers.test.ts new file mode 100644 index 0000000..d35b0c4 --- /dev/null +++ b/packages/cli/test/unit/auth-providers.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test"; +import { + PROVIDER_TEMPLATES, + getAvailableProviders, + getProviderTemplate, +} from "../../src/commands/auth-providers"; + +describe("auth-providers", () => { + describe("PROVIDER_TEMPLATES", () => { + it("has entries for all 7 providers", () => { + const providers = Object.keys(PROVIDER_TEMPLATES); + expect(providers).toContain("google"); + expect(providers).toContain("github"); + expect(providers).toContain("discord"); + expect(providers).toContain("apple"); + expect(providers).toContain("microsoft"); + expect(providers).toContain("twitter"); + expect(providers).toContain("facebook"); + expect(providers.length).toBe(7); + }); + + it("each provider has required fields", () => { + for (const [key, template] of Object.entries(PROVIDER_TEMPLATES)) { + expect(key).toBe(template.name); + expect(template.displayName).toBeString(); + expect(Array.isArray(template.envVars)).toBe(true); + expect(template.envVars.length).toBeGreaterThan(0); + expect(template.configCode).toBeString(); + expect(template.configCode.length).toBeGreaterThan(0); + expect(template.setupInstructions).toBeString(); + expect(template.docsUrl).toBeString(); + expect(template.docsUrl).toStartWith("https://"); + } + }); + + it("each provider config references correct env vars", () => { + for (const [key, template] of Object.entries(PROVIDER_TEMPLATES)) { + for (const envVar of template.envVars) { + expect(template.configCode).toContain(envVar.key); + } + } + }); + +it("each template has correct callback URL pattern", () => { + for (const [key, template] of Object.entries(PROVIDER_TEMPLATES)) { + expect(template.configCode).toContain(`/api/auth/callback/${key}`); + } + }); + }); + + describe("getProviderTemplate", () => { + it("returns template for valid provider name", () => { + const template = getProviderTemplate("google"); + expect(template).not.toBeNull(); + expect(template!.name).toBe("google"); + }); + + it("is case-insensitive", () => { + const template = getProviderTemplate("GITHUB"); + expect(template).not.toBeNull(); + expect(template!.name).toBe("github"); + }); + + it("returns null for unknown provider", () => { + const template = getProviderTemplate("nonexistent"); + expect(template).toBeNull(); + }); + }); + + describe("getAvailableProviders", () => { + it("returns 7 provider names", () => { + const providers = getAvailableProviders(); + expect(providers.length).toBe(7); + }); + + it("includes all expected providers", () => { + const providers = getAvailableProviders(); + expect(providers).toContain("google"); + expect(providers).toContain("github"); + expect(providers).toContain("discord"); + expect(providers).toContain("apple"); + expect(providers).toContain("microsoft"); + expect(providers).toContain("twitter"); + expect(providers).toContain("facebook"); + }); + }); +}); diff --git a/packages/cli/test/unit/config.test.ts b/packages/cli/test/unit/config.test.ts new file mode 100644 index 0000000..6aafe19 --- /dev/null +++ b/packages/cli/test/unit/config.test.ts @@ -0,0 +1,65 @@ +import { afterAll, afterEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { findConfigFile } from "../../src/utils/config"; + +describe("config", () => { + let tempDir: string; + + afterEach(() => { + if (tempDir) { + try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ } + } + }); + + function createTempDir(): string { + const dir = join(tmpdir(), `bb-config-test-${randomUUID().slice(0, 8)}`); + mkdirSync(dir, { recursive: true }); + return dir; + } + + describe("findConfigFile", () => { + it("discovers betterbase.config.ts", async () => { + tempDir = createTempDir(); + writeFileSync(join(tempDir, "betterbase.config.ts"), "export default {}"); + + const result = await findConfigFile(tempDir); + expect(result).toBe(join(tempDir, "betterbase.config.ts")); + }); + + it("discovers betterbase.config.js when .ts not present", async () => { + tempDir = createTempDir(); + writeFileSync(join(tempDir, "betterbase.config.js"), "export default {}"); + + const result = await findConfigFile(tempDir); + expect(result).toBe(join(tempDir, "betterbase.config.js")); + }); + + it("discovers betterbase.config.mts when .ts and .js not present", async () => { + tempDir = createTempDir(); + writeFileSync(join(tempDir, "betterbase.config.mts"), "export default {}"); + + const result = await findConfigFile(tempDir); + expect(result).toBe(join(tempDir, "betterbase.config.mts")); + }); + + it("prefers .ts variant over .js and .mts", async () => { + tempDir = createTempDir(); + writeFileSync(join(tempDir, "betterbase.config.ts"), "ts"); + writeFileSync(join(tempDir, "betterbase.config.js"), "js"); + writeFileSync(join(tempDir, "betterbase.config.mts"), "mts"); + + const result = await findConfigFile(tempDir); + expect(result).toBe(join(tempDir, "betterbase.config.ts")); + }); + + it("returns null when no config file exists", async () => { + tempDir = createTempDir(); + + const result = await findConfigFile(tempDir); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/cli/test/unit/credentials.test.ts b/packages/cli/test/unit/credentials.test.ts new file mode 100644 index 0000000..8f802d1 --- /dev/null +++ b/packages/cli/test/unit/credentials.test.ts @@ -0,0 +1,218 @@ +import { afterAll, afterEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearCredentials, + loadCredentials, + saveCredentials, + getServerUrl, + type Credentials, +} from "../../src/utils/credentials"; + +// Use sandboxed temp directory for credentials tests +const tempHomeDir = mkdtempSync(join(tmpdir(), "bb-creds-test-")); +const BETTERBASE_DIR = join(tempHomeDir, ".betterbase"); +const CREDENTIALS_FILE = join(BETTERBASE_DIR, "credentials.json"); + +// Set env var BEFORE importing the module +process.env.BB_CREDENTIALS_DIR = BETTERBASE_DIR; + +function cleanupCredentialsFile() { + try { + if (existsSync(CREDENTIALS_FILE)) { + rmSync(CREDENTIALS_FILE); + } + rmSync(tempHomeDir, { recursive: true, force: true }); + } catch { /* ignore */ } +} + +describe("credentials", () => { + afterEach(cleanupCredentialsFile); + afterAll(cleanupCredentialsFile); + + describe("saveCredentials", () => { + it("saves credentials to ~/.betterbase/credentials.json", () => { + const creds: Credentials = { + token: "test_token_123", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + + saveCredentials(creds); + + expect(existsSync(CREDENTIALS_FILE)).toBe(true); + const raw = JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8")); + expect(raw.token).toBe("test_token_123"); + expect(raw.admin_email).toBe("admin@test.com"); + expect(raw.server_url).toBe("https://api.betterbase.io"); + }); + + it("creates the directory if it does not exist", () => { + try { rmSync(BETTERBASE_DIR, { recursive: true, force: true }); } catch { /* ignore */ } + + const creds: Credentials = { + token: "test_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + + saveCredentials(creds); + + expect(existsSync(CREDENTIALS_FILE)).toBe(true); + }); + + it("overwrites existing credentials", () => { + const first: Credentials = { + token: "first_token", + admin_email: "first@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(first); + + const second: Credentials = { + token: "second_token", + admin_email: "second@test.com", + server_url: "https://other.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(second); + + const loaded = loadCredentials(); + expect(loaded).not.toBeNull(); + expect(loaded!.token).toBe("second_token"); + }); + }); + + describe("loadCredentials", () => { + it("returns null when no credentials file exists", () => { + cleanupCredentialsFile(); + const creds = loadCredentials(); + expect(creds).toBeNull(); + }); + + it("loads and validates valid credentials", () => { + const expected: Credentials = { + token: "valid_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(expected); + + const loaded = loadCredentials(); + expect(loaded).not.toBeNull(); + expect(loaded!.token).toBe(expected.token); + expect(loaded!.admin_email).toBe(expected.admin_email); + expect(loaded!.server_url).toBe(expected.server_url); + }); + + it("returns null for corrupt JSON file", () => { + mkdirSync(BETTERBASE_DIR, { recursive: true }); + writeFileSync(CREDENTIALS_FILE, "not valid json {{{"); + + const creds = loadCredentials(); + expect(creds).toBeNull(); + }); + + it("returns null for missing required fields (Zod validation)", () => { + mkdirSync(BETTERBASE_DIR, { recursive: true }); + writeFileSync(CREDENTIALS_FILE, JSON.stringify({ token: "some_token" })); + + const creds = loadCredentials(); + expect(creds).toBeNull(); + }); + + it("returns null for invalid email format", () => { + mkdirSync(BETTERBASE_DIR, { recursive: true }); + writeFileSync( + CREDENTIALS_FILE, + JSON.stringify({ + token: "some_token", + admin_email: "not-an-email", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }), + ); + + const creds = loadCredentials(); + expect(creds).toBeNull(); + }); + + it("returns null for invalid URL format", () => { + mkdirSync(BETTERBASE_DIR, { recursive: true }); + writeFileSync( + CREDENTIALS_FILE, + JSON.stringify({ + token: "some_token", + admin_email: "admin@test.com", + server_url: "not-a-url", + created_at: new Date().toISOString(), + }), + ); + + const creds = loadCredentials(); + expect(creds).toBeNull(); + }); + }); + + describe("clearCredentials", () => { + it("clears the credentials file by writing empty object", () => { + const creds: Credentials = { + token: "some_token", + admin_email: "admin@test.com", + server_url: "https://api.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + clearCredentials(); + + const content = readFileSync(CREDENTIALS_FILE, "utf-8"); + expect(JSON.parse(content)).toEqual({}); + }); + + it("does not throw when no credentials file exists", () => { + cleanupCredentialsFile(); + expect(() => clearCredentials()).not.toThrow(); + }); + }); + + describe("getServerUrl", () => { + it("returns the server URL from saved credentials", () => { + const creds: Credentials = { + token: "some_token", + admin_email: "admin@test.com", + server_url: "https://custom.betterbase.io", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + const url = getServerUrl(); + expect(url).toBe("https://custom.betterbase.io"); + }); + + it("falls back to default URL when no credentials exist", () => { + cleanupCredentialsFile(); + + const url = getServerUrl(); + expect(url).toBe("https://api.betterbase.io"); + }); + + it("removes trailing slash from URL", () => { + const creds: Credentials = { + token: "some_token", + admin_email: "admin@test.com", + server_url: "https://custom.betterbase.io/", + created_at: new Date().toISOString(), + }; + saveCredentials(creds); + + const url = getServerUrl(); + expect(url).toBe("https://custom.betterbase.io"); + }); + }); +}); diff --git a/packages/cli/test/unit/spinner.test.ts b/packages/cli/test/unit/spinner.test.ts new file mode 100644 index 0000000..5d1024a --- /dev/null +++ b/packages/cli/test/unit/spinner.test.ts @@ -0,0 +1,40 @@ +import { afterAll, afterEach, describe, expect, it } from "bun:test"; + +describe("spinner", () => { + describe("createSpinner", () => { + it("creates an Ora instance", async () => { + const ora = await import("ora"); + const spinner = ora.default("testing"); + expect(spinner).toBeDefined(); + expect(spinner.isSpinning).toBe(false); + }); + }); + + describe("withSpinner", () => { + it("calls task and returns result on success", async () => { + const { withSpinner } = await import("../../src/utils/spinner"); + const result = await withSpinner( + "Testing spinner", + async () => "success_result", + { successText: "Done" }, + ); + expect(result).toBe("success_result"); + }); + + it("re-throws error after catching task failure", async () => { + const { withSpinner } = await import("../../src/utils/spinner"); + let caught = false; + try { + await withSpinner( + "Testing spinner failure", + async () => { throw new Error("task failed"); }, + { failText: "Failed" }, + ); + } catch (e: unknown) { + caught = true; + expect((e as Error).message).toBe("task failed"); + } + expect(caught).toBe(true); + }); + }); +}); diff --git a/packages/cli/test/webhook-commands.test.ts b/packages/cli/test/webhook-commands.test.ts deleted file mode 100644 index fca3d91..0000000 --- a/packages/cli/test/webhook-commands.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Webhook CLI Commands Test Suite - * - * Tests for untested webhook command functions in cli/src/commands/webhook.ts - */ - -import { describe, expect, it } from "bun:test"; - -describe("Webhook CLI Commands", () => { - describe("runWebhookCreateCommand", () => { - it("should create webhook with valid config", async () => { - expect(true).toBe(true); - }); - - it("should require project root", async () => { - expect(true).toBe(true); - }); - - it("should validate webhook URL", async () => { - expect(true).toBe(true); - }); - - it("should handle duplicate webhook IDs", async () => { - expect(true).toBe(true); - }); - }); - - describe("runWebhookListCommand", () => { - it("should list all webhooks", async () => { - expect(true).toBe(true); - }); - - it("should show webhook details", async () => { - expect(true).toBe(true); - }); - - it("should handle empty webhook list", async () => { - expect(true).toBe(true); - }); - }); - - describe("runWebhookTestCommand", () => { - it("should test webhook with sample payload", async () => { - expect(true).toBe(true); - }); - - it("should require webhook ID", async () => { - expect(true).toBe(true); - }); - - it("should handle non-existent webhook", async () => { - expect(true).toBe(true); - }); - - it("should show test results", async () => { - expect(true).toBe(true); - }); - }); - - describe("runWebhookLogsCommand", () => { - it("should show webhook delivery logs", async () => { - expect(true).toBe(true); - }); - - it("should filter logs by webhook ID", async () => { - expect(true).toBe(true); - }); - - it("should handle no logs available", async () => { - expect(true).toBe(true); - }); - - it("should show success/failure status", async () => { - expect(true).toBe(true); - }); - }); - - describe("runWebhookCommand", () => { - it("should route to correct subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show help when no subcommand", async () => { - expect(true).toBe(true); - }); - - it("should show error for unknown subcommand", async () => { - expect(true).toBe(true); - }); - }); -}); - -// Placeholder tests to ensure test infrastructure works -describe("Webhook CLI Command Stubs", () => { - it("should have placeholder tests for create", () => { - const config = { id: "test-webhook", url: "https://example.com" }; - expect(config.id).toBe("test-webhook"); - }); - - it("should have placeholder tests for list", () => { - const webhooks = [{ id: "webhook1" }, { id: "webhook2" }]; - expect(webhooks.length).toBe(2); - }); - - it("should have placeholder tests for test", () => { - const result = { success: true, statusCode: 200 }; - expect(result.success).toBe(true); - }); - - it("should have placeholder tests for logs", () => { - const logs = [{ timestamp: new Date(), success: true }]; - expect(logs.length).toBe(1); - }); -}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 1deb887..0b13c4c 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"], - "outDir": "dist" + "outDir": "dist", + "esModuleInterop": true, + "skipLibCheck": false, + "moduleResolution": "bundler" }, "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/test_logs.txt b/test_logs.txt new file mode 100644 index 0000000..bc3686f --- /dev/null +++ b/test_logs.txt @@ -0,0 +1,4008 @@ +$ bunx turbo run test 2>&1 | tee /tmp/test.log; echo ''; echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; echo '📋 TEST SUMMARY'; echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; grep -oP '\d+ pass' /tmp/test.log | awk '{sum+=$1} END {print "✅ Passed: " sum}'; grep -oP '\d+ fail' /tmp/test.log | awk '{sum+=$1} END {print "❌ Failed: " sum}'; grep -oP '\d+ skip' /tmp/test.log | awk '{sum+=$1} END {if (sum>0) print "⏭️ Skipped: " sum}'; grep -oP 'Ran \d+ tests?' /tmp/test.log | grep -oP '\d+' | awk '{sum+=$1} END {print "📝 Total Tests: " sum}'; echo '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' +• turbo 2.8.12 +• Packages in scope: @betterbase/cli, @betterbase/client, @betterbase/core, @betterbase/server, @betterbase/shared, betterbase-base-template, betterbase-dashboard, my-betterbase-project +• Running test in 8 packages +• Remote caching disabled +@betterbase/shared:test: cache bypass, force executing 8b4400cf2a26c3d0 +@betterbase/cli:test: cache bypass, force executing 3b62aac8b87fe90d +betterbase-base-template:test: cache bypass, force executing 37bf3c233de27165 +@betterbase/client:test: cache bypass, force executing c0475020c0412845 +@betterbase/core:test: cache bypass, force executing 74db4d71cfe38fe5 +@betterbase/client:build: cache hit, replaying logs cfb18f063fc09548 +@betterbase/client:build: $ bun run src/build.ts +@betterbase/client:build: ✅ Build complete! +@betterbase/client:test: bun test v1.3.13 (bf2e2cec) +@betterbase/client:test: $ bun test +@betterbase/shared:test: bun test v1.3.13 (bf2e2cec) +@betterbase/shared:test: $ bun test +betterbase-base-template:test: $ bun test +betterbase-base-template:test: bun test v1.3.13 (bf2e2cec) +@betterbase/cli:test: $ bun test +@betterbase/cli:test: bun test v1.3.13 (bf2e2cec) +@betterbase/core:test: $ bun test +@betterbase/core:test: bun test v1.3.13 (bf2e2cec) +@betterbase/cli:test: +@betterbase/cli:test: test/route-scanner.test.ts: +@betterbase/shared:test: +@betterbase/shared:test: test/shared.test.ts: +betterbase-base-template:test: +betterbase-base-template:test: test/health.test.ts: +@betterbase/client:test: +@betterbase/client:test: test/query-builder.test.ts: +@betterbase/core:build: cache hit, replaying logs d66d743fce791100 +@betterbase/core:build: $ bun build ./src/index.ts --outdir ./dist --target node +@betterbase/core:build: Bundled 439 modules in 1034ms +@betterbase/core:build: +@betterbase/core:build: index.js 2.0 MB (entry point) +@betterbase/core:build: +@betterbase/server:test: cache bypass, force executing f882ed10692d9b29 +@betterbase/server:test: $ bun test +@betterbase/core:test: +@betterbase/core:test: test/rls-types.test.ts: +@betterbase/shared:test: (pass) shared/errors > BetterBaseError > is a subclass of Error +@betterbase/server:test: bun test v1.3.13 (bf2e2cec) +@betterbase/shared:test: (pass) shared/errors > BetterBaseError > preserves message +@betterbase/shared:test: (pass) shared/errors > BetterBaseError > has code property [1.00ms] +@betterbase/shared:test: (pass) shared/errors > BetterBaseError > has default statusCode +@betterbase/shared:test: (pass) shared/errors > BetterBaseError > accepts custom statusCode +@betterbase/shared:test: (pass) shared/errors > BetterBaseError > has correct name +@betterbase/shared:test: (pass) shared/errors > ValidationError > has correct code and statusCode +@betterbase/shared:test: (pass) shared/errors > ValidationError > is subclass of BetterBaseError +@betterbase/shared:test: (pass) shared/errors > NotFoundError > creates message with resource name +@betterbase/shared:test: (pass) shared/errors > UnauthorizedError > has correct defaults +@betterbase/shared:test: (pass) shared/errors > UnauthorizedError > accepts custom message +@betterbase/shared:test: (pass) shared/constants > exports version string +@betterbase/shared:test: (pass) shared/constants > exports default port +@betterbase/shared:test: (pass) shared/constants > exports default db path +@betterbase/shared:test: (pass) shared/constants > exports context file name +@betterbase/shared:test: (pass) shared/constants > exports config file name +@betterbase/shared:test: (pass) shared/constants > exports migrations dir +@betterbase/shared:test: (pass) shared/constants > exports functions dir +@betterbase/shared:test: (pass) shared/constants > exports policies dir +@betterbase/shared:test: (pass) shared/utils > serializeError > serializes error properties [1.00ms] +@betterbase/shared:test: (pass) shared/utils > isValidProjectName > accepts valid lowercase names +@betterbase/shared:test: (pass) shared/utils > isValidProjectName > rejects invalid names +@betterbase/shared:test: (pass) shared/utils > toCamelCase > converts snake_case to camelCase +@betterbase/shared:test: (pass) shared/utils > toCamelCase > handles empty string +@betterbase/shared:test: (pass) shared/utils > toSnakeCase > converts camelCase to snake_case +@betterbase/shared:test: (pass) shared/utils > toSnakeCase > converts PascalCase to snake_case +@betterbase/shared:test: (pass) shared/utils > toSnakeCase > handles empty string +@betterbase/shared:test: (pass) shared/utils > safeJsonParse > parses valid JSON +@betterbase/shared:test: (pass) shared/utils > safeJsonParse > returns null for invalid JSON +@betterbase/shared:test: (pass) shared/utils > formatBytes > formats bytes correctly [5.00ms] +@betterbase/shared:test: (pass) shared/utils > formatBytes > throws for negative bytes +@betterbase/shared:test: +@betterbase/shared:test: test/constants.test.ts: +@betterbase/core:test: (pass) RLS Types > definePolicy > should create a basic policy with table name +@betterbase/core:test: (pass) RLS Types > definePolicy > should create a policy with multiple operations +@betterbase/core:test: (pass) RLS Types > definePolicy > should create a policy with using clause +@betterbase/core:test: (pass) RLS Types > definePolicy > should create a policy with withCheck clause +@betterbase/core:test: (pass) RLS Types > definePolicy > should create a policy with all clauses +@betterbase/core:test: (pass) RLS Types > definePolicy > should handle empty config +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return true for valid policy definition +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return true for policy with minimum required fields +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return false for null +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return false for undefined +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return false for primitive values +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return false for empty object +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return false for object without table +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return false for object with empty table string +@betterbase/core:test: (pass) RLS Types > isPolicyDefinition > should return false for object with non-string table +@betterbase/shared:test: (pass) constants > BETTERBASE_VERSION > should export the correct version string +@betterbase/shared:test: (pass) constants > BETTERBASE_VERSION > should be a non-empty string +@betterbase/shared:test: (pass) constants > DEFAULT_PORT > should export the correct default port +@betterbase/shared:test: (pass) constants > DEFAULT_PORT > should be a valid HTTP port number +@betterbase/shared:test: (pass) constants > DEFAULT_DB_PATH > should export the correct default database path +@betterbase/shared:test: (pass) constants > DEFAULT_DB_PATH > should be a non-empty string +@betterbase/shared:test: (pass) constants > CONTEXT_FILE_NAME > should export the correct context file name +@betterbase/shared:test: (pass) constants > CONTEXT_FILE_NAME > should be a valid file name with json extension +@betterbase/shared:test: (pass) constants > CONFIG_FILE_NAME > should export the correct config file name +@betterbase/shared:test: (pass) constants > CONFIG_FILE_NAME > should be a TypeScript file [1.00ms] +@betterbase/shared:test: (pass) constants > MIGRATIONS_DIR > should export the correct migrations directory name +@betterbase/shared:test: (pass) constants > MIGRATIONS_DIR > should be a non-empty string +@betterbase/shared:test: (pass) constants > FUNCTIONS_DIR > should export the correct functions directory path +@betterbase/shared:test: (pass) constants > FUNCTIONS_DIR > should be a valid directory path +@betterbase/shared:test: (pass) constants > POLICIES_DIR > should export the correct policies directory path +@betterbase/shared:test: (pass) constants > POLICIES_DIR > should be a valid directory path +@betterbase/shared:test: +@betterbase/shared:test: test/errors.test.ts: +@betterbase/core:test: (pass) RLS Types > mergePolicies > should merge policies for the same table [6.00ms] +@betterbase/core:test: (pass) RLS Types > mergePolicies > should keep separate policies for different tables +@betterbase/core:test: (pass) RLS Types > mergePolicies > should handle three policies for same table +@betterbase/core:test: (pass) RLS Types > mergePolicies > should handle empty array +@betterbase/core:test: (pass) RLS Types > mergePolicies > should handle single policy +@betterbase/core:test: (pass) RLS Types > mergePolicies > should handle using and withCheck merging +@betterbase/core:test: (pass) RLS Types > mergePolicies > should preserve later values when merging duplicate operations +@betterbase/core:test: +@betterbase/core:test: test/graphql-sdl-exporter.test.ts: +@betterbase/shared:test: (pass) errors > BetterBaseError > should create an error with message, code, and default status code +@betterbase/shared:test: (pass) errors > BetterBaseError > should create an error with custom status code +@betterbase/shared:test: (pass) errors > BetterBaseError > should be an instance of Error +@betterbase/shared:test: (pass) errors > BetterBaseError > should have stack trace +@betterbase/shared:test: (pass) errors > ValidationError > should create a validation error with correct defaults +@betterbase/shared:test: (pass) errors > ValidationError > should be an instance of BetterBaseError +@betterbase/shared:test: (pass) errors > ValidationError > should be an instance of Error +@betterbase/shared:test: (pass) errors > NotFoundError > should create a not found error with formatted message +@betterbase/shared:test: (pass) errors > NotFoundError > should create error for different resources +@betterbase/shared:test: (pass) errors > NotFoundError > should be an instance of BetterBaseError +@betterbase/shared:test: (pass) errors > NotFoundError > should be an instance of Error +@betterbase/shared:test: (pass) errors > UnauthorizedError > should create an unauthorized error with default message +@betterbase/shared:test: (pass) errors > UnauthorizedError > should create an unauthorized error with custom message +@betterbase/shared:test: (pass) errors > UnauthorizedError > should be an instance of BetterBaseError +@betterbase/shared:test: (pass) errors > UnauthorizedError > should be an instance of Error +@betterbase/shared:test: +@betterbase/shared:test: test/types.test.ts: +@betterbase/shared:test: (pass) types > SerializedError > should allow creating a serialized error object +@betterbase/shared:test: (pass) types > SerializedError > should allow optional properties +@betterbase/shared:test: (pass) types > BetterBaseResponse > should allow creating a response with data +@betterbase/shared:test: (pass) types > BetterBaseResponse > should allow creating a response with error +@betterbase/shared:test: (pass) types > BetterBaseResponse > should allow creating a response with serialized error +@betterbase/shared:test: (pass) types > BetterBaseResponse > should allow adding count and pagination +@betterbase/shared:test: (pass) types > DBEvent > should allow creating an INSERT event +@betterbase/shared:test: (pass) types > DBEvent > should allow creating an UPDATE event with old_record +@betterbase/server:test: +@betterbase/server:test: test/inngest.test.ts: +@betterbase/shared:test: (pass) types > DBEvent > should allow creating a DELETE event +@betterbase/shared:test: (pass) types > DBEventType > should allow INSERT as a valid DBEventType +@betterbase/shared:test: (pass) types > DBEventType > should allow UPDATE as a valid DBEventType +@betterbase/shared:test: (pass) types > DBEventType > should allow DELETE as a valid DBEventType +@betterbase/shared:test: (pass) types > ProviderType > should allow neon as a valid provider +@betterbase/shared:test: (pass) types > ProviderType > should allow turso as a valid provider +@betterbase/shared:test: (pass) types > ProviderType > should allow planetscale as a valid provider [1.00ms] +@betterbase/shared:test: (pass) types > ProviderType > should allow supabase as a valid provider +@betterbase/shared:test: (pass) types > ProviderType > should allow postgres as a valid provider +@betterbase/shared:test: (pass) types > ProviderType > should allow managed as a valid provider +@betterbase/shared:test: (pass) types > PaginationParams > should allow creating pagination params with limit only +@betterbase/shared:test: (pass) types > PaginationParams > should allow creating pagination params with offset only +@betterbase/shared:test: (pass) types > PaginationParams > should allow creating pagination params with both limit and offset +@betterbase/shared:test: (pass) types > PaginationParams > should allow empty pagination params +@betterbase/shared:test: +@betterbase/shared:test: test/utils.test.ts: +@betterbase/shared:test: (pass) utils > serializeError > should serialize an Error object [1.00ms] +@betterbase/shared:test: (pass) utils > serializeError > should include all properties from error +@betterbase/shared:test: (pass) utils > serializeError > should handle custom error names +@betterbase/shared:test: (pass) utils > isValidProjectName > valid project names > should accept simple lowercase names +@betterbase/shared:test: (pass) utils > isValidProjectName > valid project names > should accept names with numbers +@betterbase/shared:test: (pass) utils > isValidProjectName > valid project names > should accept names with hyphens +@betterbase/shared:test: (pass) utils > isValidProjectName > valid project names > should accept names starting with letter and ending with number +@betterbase/shared:test: (pass) utils > isValidProjectName > valid project names > should accept single letter names +@betterbase/shared:test: (pass) utils > isValidProjectName > valid project names > should accept complex valid names +@betterbase/shared:test: (pass) utils > isValidProjectName > invalid project names > should reject empty strings +@betterbase/shared:test: (pass) utils > isValidProjectName > invalid project names > should reject names starting with numbers +@betterbase/shared:test: (pass) utils > isValidProjectName > invalid project names > should reject names starting with hyphen +@betterbase/shared:test: (pass) utils > isValidProjectName > invalid project names > should reject names ending with hyphen +@betterbase/shared:test: (pass) utils > isValidProjectName > invalid project names > should reject names with uppercase letters +@betterbase/shared:test: (pass) utils > isValidProjectName > invalid project names > should reject names with special characters +@betterbase/shared:test: (pass) utils > isValidProjectName > invalid project names > should reject whitespace-only strings +@betterbase/shared:test: (pass) utils > toCamelCase > should convert snake_case to camelCase +@betterbase/shared:test: (pass) utils > toCamelCase > should convert multiple underscores +@betterbase/shared:test: (pass) utils > toCamelCase > should handle single word +@betterbase/shared:test: (pass) utils > toCamelCase > should handle empty string +@betterbase/shared:test: (pass) utils > toCamelCase > should handle strings with no underscores +@betterbase/shared:test: (pass) utils > toCamelCase > should handle leading underscore +@betterbase/shared:test: (pass) utils > toSnakeCase > should convert camelCase to snake_case +@betterbase/shared:test: (pass) utils > toSnakeCase > should convert PascalCase to snake_case +@betterbase/shared:test: (pass) utils > toSnakeCase > should handle single word +@betterbase/shared:test: (pass) utils > toSnakeCase > should handle empty string +@betterbase/shared:test: (pass) utils > toSnakeCase > should handle consecutive uppercase letters +@betterbase/shared:test: (pass) utils > toSnakeCase > should handle numbers in string +@betterbase/shared:test: (pass) utils > toSnakeCase > should handle all uppercase +@betterbase/shared:test: (pass) utils > safeJsonParse > should parse valid JSON +@betterbase/shared:test: (pass) utils > safeJsonParse > should parse JSON arrays +@betterbase/shared:test: (pass) utils > safeJsonParse > should return null for invalid JSON +@betterbase/shared:test: (pass) utils > safeJsonParse > should return null for empty string +@betterbase/shared:test: (pass) utils > safeJsonParse > should return null for partial JSON +@betterbase/shared:test: (pass) utils > safeJsonParse > should parse numbers +@betterbase/shared:test: (pass) utils > safeJsonParse > should parse booleans +@betterbase/server:test: (pass) Inngest client > Module exports > should export deliverWebhook function [1.00ms] +@betterbase/shared:test: (pass) utils > safeJsonParse > should parse null +@betterbase/shared:test: (pass) utils > formatBytes > should format 0 bytes +@betterbase/shared:test: (pass) utils > formatBytes > should format bytes in binary units +@betterbase/shared:test: (pass) utils > formatBytes > should format with decimal places +@betterbase/shared:test: (pass) utils > formatBytes > should handle small values +@betterbase/shared:test: (pass) utils > formatBytes > should handle large values +@betterbase/shared:test: (pass) utils > formatBytes > should throw RangeError for negative bytes +@betterbase/shared:test: (pass) utils > formatBytes > should throw with correct message +@betterbase/shared:test: +@betterbase/shared:test: 128 pass +@betterbase/shared:test: 0 fail +@betterbase/shared:test: 199 expect() calls +@betterbase/shared:test: Ran 128 tests across 5 files. [163.00ms] +@betterbase/server:test: (pass) Inngest client > Module exports > should export evaluateNotificationRule function [5.00ms] +@betterbase/server:test: (pass) Inngest client > Module exports > should export exportProjectUsers function +@betterbase/server:test: (pass) Inngest client > Module exports > should export pollNotificationRules function +@betterbase/server:test: (pass) Inngest client > Module exports > should export allInngestFunctions array with 4 functions +@betterbase/server:test: (pass) Inngest client > Module exports > should have correct function IDs in allInngestFunctions [1.00ms] +@betterbase/server:test: (pass) Inngest client > inngest.send event triggering > should send webhook deliver event via inngest.send [1.00ms] +@betterbase/server:test: (pass) Inngest client > inngest.send event triggering > should send notification evaluate event via inngest.send [1.00ms] +@betterbase/server:test: (pass) Inngest client > inngest.send event triggering > should send export users event via inngest.send +@betterbase/server:test: (pass) Inngest client > Database pool interactions > should get pool from db module [5.00ms] +@betterbase/server:test: (pass) Inngest client > Database pool interactions > should call pool.query for export job insert +@betterbase/server:test: (pass) Inngest client > Database pool interactions > should call pool.query for webhook secret lookup +@betterbase/server:test: (pass) Inngest client > Database pool interactions > should call pool.query for notification rules +@betterbase/server:test: (pass) Inngest client > Database pool interactions > should call pool.query for request logs metric +@betterbase/server:test: (pass) Inngest environment configuration > BASE_URL scenarios > should use cloud API when INNGEST_BASE_URL is undefined +@betterbase/server:test: (pass) Inngest environment configuration > BASE_URL scenarios > should use local dev server when INNGEST_BASE_URL is localhost:8288 +@betterbase/server:test: (pass) Inngest environment configuration > BASE_URL scenarios > should use self-hosted container when INNGEST_BASE_URL is inngest:8288 +@betterbase/server:test: (pass) Inngest environment configuration > Signing key > should have default signing key for development +@betterbase/server:test: (pass) Inngest environment configuration > Signing key > should use provided signing key in production +@betterbase/server:test: (pass) Inngest environment configuration > Event key > should have default event key for development +@betterbase/server:test: (pass) Inngest environment configuration > Event key > should use provided event key in production +@betterbase/server:test: +@betterbase/server:test: test/instance.test.ts: +@betterbase/server:test: (pass) instance routes > GET /admin/instance > should return settings as key-value object [6.00ms] +@betterbase/server:test: (pass) instance routes > GET /admin/instance > should return empty object when no settings exist +@betterbase/server:test: (pass) instance routes > GET /admin/instance/health > should return health status with database latency [1.00ms] +@betterbase/server:test: (pass) instance routes > GET /admin/instance/health > should handle database connection error gracefully +@betterbase/server:test: (pass) instance routes > PATCH /admin/instance > should update only provided keys [3.00ms] +@betterbase/server:test: (pass) instance routes > PATCH /admin/instance > should validate input with zod schema +@betterbase/server:test: +@betterbase/server:test: test/iac-routes.test.ts: +@betterbase/server:test: (pass) IaC Routes > GET /:projectId/iac/schema > should return schema with tables and columns [33.00ms] +@betterbase/server:test: (pass) IaC Routes > GET /:projectId/iac/schema > should handle empty schema [3.00ms] +@betterbase/server:test: (pass) IaC Routes > GET /:projectId/iac/functions > should return IaC functions [3.00ms] +@betterbase/server:test: (pass) IaC Routes > GET /:projectId/iac/functions > should handle empty functions [3.00ms] +@betterbase/server:test: (pass) IaC Routes > GET /:projectId/iac/jobs > should return scheduled jobs [1.00ms] +@betterbase/server:test: (pass) IaC Routes > GET /:projectId/iac/realtime > should return realtime stats [13.00ms] +@betterbase/server:test: (pass) IaC Routes > POST /:projectId/iac/query > should execute SELECT query [5.00ms] +@betterbase/server:test: (pass) IaC Routes > POST /:projectId/iac/query > should reject non-SELECT queries [1.00ms] +@betterbase/server:test: (pass) IaC Routes > POST /:projectId/iac/query > should reject empty SQL [4.00ms] +@betterbase/server:test: (pass) IaC Routes > POST /:projectId/iac/query > should handle query errors +@betterbase/server:test: +@betterbase/server:test: test/api-keys.test.ts: +@betterbase/server:test: (pass) API Keys > key generation > should generate keys with bb_live_ prefix +@betterbase/server:test: (pass) API Keys > key generation > should generate unique keys each time +@betterbase/server:test: (pass) API Keys > key generation > should generate key prefix of 8 characters +@betterbase/server:test: (pass) API Keys > key hashing > should produce SHA-256 hash [1.00ms] +@betterbase/server:test: (pass) API Keys > key hashing > should produce consistent hash for same input [1.00ms] +@betterbase/server:test: (pass) API Keys > key hashing > should produce different hashes for different inputs +@betterbase/server:test: (pass) API Keys > API key routes > POST /admin/api-keys > should create API key and return plaintext once +@betterbase/server:test: (pass) API Keys > API key routes > POST /admin/api-keys > should allow empty scopes for full access +@betterbase/server:test: (pass) API Keys > API key routes > GET /admin/api-keys > should return keys without exposing key_hash +@betterbase/server:test: (pass) API Keys > API key routes > DELETE /admin/api-keys/:id > should only delete keys owned by the admin +@betterbase/server:test: (pass) API Keys > API key routes > DELETE /admin/api-keys/:id > should return 404 when key not found or not owned +@betterbase/server:test: (pass) API Keys > API key authentication > should verify key hash matches +@betterbase/server:test: (pass) API Keys > API key authentication > should reject expired keys [1.00ms] +@betterbase/server:test: (pass) API Keys > API key authentication > should update last_used_at on successful auth [1.00ms] +@betterbase/server:test: +@betterbase/server:test: test/routes.test.ts: +@betterbase/server:test: (pass) routes logic tests > SMTP routes logic > should mask password when present +@betterbase/server:test: (pass) routes logic tests > SMTP routes logic > should handle missing password gracefully +@betterbase/server:test: (pass) routes logic tests > metrics enhanced logic > should support different period intervals +@betterbase/server:test: (pass) routes logic tests > metrics enhanced logic > should handle unknown period with default +@betterbase/server:test: (pass) routes logic tests > notification rules logic > should have valid metric enum values +@betterbase/server:test: (pass) routes logic tests > notification rules logic > should have valid channel enum values +@betterbase/server:test: (pass) routes logic tests > notification rules logic > should evaluate threshold breach correctly +@betterbase/server:test: (pass) routes logic tests > notification rules logic > should not breach when value is below threshold +@betterbase/server:test: (pass) routes logic tests > Inngest webhook delivery logic > should evaluate threshold breach correctly +@betterbase/server:test: (pass) routes logic tests > Inngest webhook delivery logic > should generate valid HMAC-SHA256 signature format +@betterbase/server:test: (pass) routes logic tests > Inngest webhook delivery logic > should calculate retry attempt from failed attempt +@betterbase/server:test: (pass) routes logic tests > Inngest webhook delivery logic > should use webhook ID in concurrency key format +@betterbase/server:test: (pass) routes logic tests > Inngest cron polling logic > should parse cron expression into 5 parts [1.00ms] +@betterbase/server:test: (pass) routes logic tests > Inngest cron polling logic > should calculate error rate percentage +@betterbase/server:test: (pass) routes logic tests > Inngest cron polling logic > should handle zero total requests without division by zero +@betterbase/server:test: (pass) unit logic tests > schema name generation > should generate correct schema name +@betterbase/server:test: (pass) unit logic tests > key format validation > should accept valid env var keys +@betterbase/server:test: (pass) unit logic tests > key format validation > should reject invalid env var keys [1.00ms] +@betterbase/server:test: (pass) unit logic tests > allowed auth config keys > should include provider configs +@betterbase/server:test: (pass) unit logic tests > allowed auth config keys > should reject unknown keys +@betterbase/server:test: +@betterbase/server:test: test/project-scoped.test.ts: +@betterbase/server:test: (pass) project-scoped routes > schemaName helper > should generate correct schema name from slug +@betterbase/server:test: (pass) project-scoped routes > schemaName helper > should handle slug with hyphens +@betterbase/server:test: (pass) project-scoped routes > project middleware > should verify project exists before routing [1.00ms] +@betterbase/server:test: (pass) project-scoped routes > project middleware > should return 404 when project not found +@betterbase/server:test: (pass) project-scoped routes > users route > should query users with filtering +@betterbase/server:test: (pass) project-scoped routes > users route > should handle search filter +@betterbase/server:test: (pass) project-scoped routes > users route > should handle banned filter +@betterbase/server:test: (pass) project-scoped routes > ban/unban user > should structure the ban operation correctly +@betterbase/server:test: (pass) project-scoped routes > auth-config route > should have allowed keys whitelist +@betterbase/server:test: (pass) project-scoped routes > auth-config route > should validate key is in allowed list +@betterbase/server:test: (pass) project-scoped routes > env vars route > should mask secret values in response +@betterbase/server:test: (pass) project-scoped routes > env vars route > should validate key format (uppercase alphanumeric with underscores) +@betterbase/server:test: (pass) project-scoped routes > database introspection > should construct correct information_schema query +@betterbase/server:test: (pass) project-scoped routes > webhooks route > should construct webhook delivery stats query +@betterbase/server:test: (pass) project-scoped routes > functions route > should construct function invocations query +@betterbase/server:test: (pass) project-scoped routes > functions route > should construct function stats query with aggregation +@betterbase/server:test: +@betterbase/server:test: test/audit.test.ts: +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > execute() makes a GET request [19.00ms] +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > execute() targets /api/
[1.00ms] +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > .select() appends select param to URL +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > .eq() appends filter to URL +@betterbase/server:test: (pass) audit utility > getClientIp > should extract IP from x-forwarded-for header +@betterbase/server:test: (pass) audit utility > getClientIp > should extract IP from x-real-ip header when x-forwarded-for is missing +@betterbase/server:test: (pass) audit utility > getClientIp > should return 'unknown' when no IP headers are present +@betterbase/server:test: (pass) audit utility > getClientIp > should handle empty x-forwarded-for +@betterbase/server:test: (pass) audit utility > writeAuditLog > should insert audit log entry +@betterbase/server:test: (pass) audit utility > writeAuditLog > should handle minimal entry with only action +@betterbase/server:test: (pass) audit utility > writeAuditLog > should include beforeData and afterData as JSON strings [2.00ms] +@betterbase/server:test: (pass) audit utility > writeAuditLog > should handle undefined optional fields +@betterbase/server:test: [audit] Failed to write log: 97 | +@betterbase/server:test: 98 | expect(mockPool.query).toHaveBeenCalled(); +@betterbase/server:test: 99 | }); +@betterbase/server:test: 100 | +@betterbase/server:test: 101 | it("should not throw on database error (fire and forget)", async () => { +@betterbase/server:test: 102 | mockPool.query.mockRejectedValueOnce(new Error("DB error")); +@betterbase/server:test: ^ +@betterbase/server:test: error: DB error +@betterbase/server:test: at (/workspaces/Betterbase/packages/server/test/audit.test.ts:102:45) +@betterbase/server:test: +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > .limit() appends limit param to URL [9.00ms] +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > .offset() appends offset param to URL [1.00ms] +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > .order() appends sort param to URL +@betterbase/client:test: (pass) QueryBuilder — HTTP request construction > .in() sends JSON-encoded array [5.00ms] +@betterbase/client:test: (pass) QueryBuilder — response handling > returns data array on success [1.00ms] +@betterbase/client:test: (pass) QueryBuilder — response handling > returns error: null on success +@betterbase/client:test: (pass) QueryBuilder — response handling > returns error and null data on 500 +@betterbase/client:test: (pass) QueryBuilder — response handling > returns error and null data when fetch throws [3.00ms] +@betterbase/server:test: (pass) audit utility > writeAuditLog > should not throw on database error (fire and forget) [12.00ms] +@betterbase/client:test: (pass) QueryBuilder — response handling > is single-use — second execute() returns error +@betterbase/server:test: (pass) audit utility > AuditAction type > should accept valid audit actions +@betterbase/server:test: +@betterbase/server:test: test/roles.test.ts: +@betterbase/client:test: (pass) QueryBuilder — chaining > methods are chainable and return the same builder instance [2.00ms] +@betterbase/client:test: (pass) QueryBuilder — chaining > .eq() with special characters produces a parseable URL [3.00ms] +@betterbase/client:test: (pass) QueryBuilder — insert / update / delete > insert() sends POST request [1.00ms] +@betterbase/server:test: (pass) RBAC schema > roles table > should have correct structure for system roles [1.00ms] +@betterbase/server:test: (pass) RBAC schema > roles table > should include unique constraint on name +@betterbase/server:test: (pass) RBAC schema > permissions table > should have permissions for all domains +@betterbase/server:test: (pass) RBAC schema > permissions table > should have standard actions per domain [1.00ms] +@betterbase/server:test: (pass) RBAC schema > role_permissions mapping > should assign all permissions to owner role +@betterbase/server:test: (pass) RBAC schema > role_permissions mapping > should exclude settings_edit from admin role +@betterbase/server:test: (pass) RBAC schema > role_permissions mapping > should only include view permissions for viewer role +@betterbase/server:test: (pass) RBAC schema > admin_roles assignment > should support global (NULL) project scope +@betterbase/server:test: (pass) RBAC schema > admin_roles assignment > should support project-scoped assignments +@betterbase/server:test: (pass) RBAC schema > admin_roles assignment > should enforce unique constraint on admin_user_id + role_id + project_id +@betterbase/server:test: (pass) role routes > GET /admin/roles > should return roles with permissions array +@betterbase/server:test: (pass) role routes > POST /admin/roles/assignments > should create assignment with provided data +@betterbase/server:test: (pass) role routes > POST /admin/roles/assignments > should handle ON CONFLICT DO NOTHING +@betterbase/server:test: (pass) role routes > DELETE /admin/roles/assignments/:id > should return error when assignment not found +@betterbase/server:test: +@betterbase/server:test: 111 pass +@betterbase/server:test: 0 fail +@betterbase/server:test: 205 expect() calls +@betterbase/server:test: Ran 111 tests across 8 files. [340.00ms] +@betterbase/client:test: (pass) QueryBuilder — insert / update / delete > update() sends PATCH request [2.00ms] +@betterbase/client:test: (pass) QueryBuilder — insert / update / delete > delete() sends DELETE request +@betterbase/client:test: (pass) QueryBuilder — insert / update / delete > single() sends GET to /api/
/ +@betterbase/client:test: +@betterbase/client:test: test/client.test.ts: +@betterbase/client:test: (pass) @betterbase/client > creates client with config +@betterbase/client:test: (pass) @betterbase/client > from creates query builder [1.00ms] +@betterbase/client:test: (pass) @betterbase/client > execute sends chained query with key header +@betterbase/client:test: (pass) @betterbase/client > execute sends simple request [1.00ms] +@betterbase/client:test: (pass) @betterbase/client > client has auth property with methods +@betterbase/client:test: (pass) @betterbase/client > client has realtime property +@betterbase/client:test: (pass) @betterbase/client > client has storage property +@betterbase/client:test: (pass) @betterbase/client > client requires url parameter [3.00ms] +@betterbase/client:test: +@betterbase/client:test: test/auth.test.ts: +@betterbase/client:test: (pass) AuthClient > constructor > creates AuthClient with default storage when no storage provided [1.00ms] +@betterbase/client:test: (pass) AuthClient > constructor > creates AuthClient with custom storage +@betterbase/client:test: (pass) AuthClient > constructor > creates AuthClient with auth state change callback +@betterbase/client:test: (pass) AuthClient > signUp > returns success with user and session on successful signup [1.00ms] +@betterbase/client:test: (pass) AuthClient > signUp > stores session token in storage on successful signup +@betterbase/client:test: (pass) AuthClient > signUp > calls auth state change callback on successful signup +@betterbase/client:test: (pass) AuthClient > signUp > returns AuthError when signup fails with error response [1.00ms] +@betterbase/client:test: (pass) AuthClient > signUp > returns NetworkError when network request fails +@betterbase/client:test: (pass) AuthClient > signIn > returns success with user and session on successful signin [1.00ms] +@betterbase/client:test: (pass) AuthClient > signIn > stores session token in storage on successful signin +@betterbase/client:test: (pass) AuthClient > signIn > returns AuthError when signin fails with invalid credentials +@betterbase/client:test: (pass) AuthClient > signIn > returns NetworkError when network request fails +@betterbase/client:test: (pass) AuthClient > signOut > returns success on successful signout [1.00ms] +@betterbase/client:test: (pass) AuthClient > signOut > removes session token from storage on signout +@betterbase/client:test: (pass) AuthClient > signOut > calls auth state change callback with null on signout +@betterbase/client:test: (pass) AuthClient > signOut > returns AuthError when signout fails +@betterbase/client:test: (pass) AuthClient > signOut > handles network error during signout gracefully [1.00ms] +@betterbase/client:test: (pass) AuthClient > getSession > returns success with user and session when session exists +@betterbase/client:test: (pass) AuthClient > getSession > returns null data without error when no session exists +@betterbase/client:test: (pass) AuthClient > getSession > returns AuthError when session retrieval fails [1.00ms] +@betterbase/client:test: (pass) AuthClient > getSession > returns NetworkError when network request fails +@betterbase/client:test: (pass) AuthClient > getToken > returns token from storage when present +@betterbase/client:test: (pass) AuthClient > getToken > returns null when no token in storage [1.00ms] +@betterbase/client:test: (pass) AuthClient > setToken > stores token in storage when token is provided +@betterbase/client:test: (pass) AuthClient > setToken > calls auth state change callback when token is set +@betterbase/client:test: (pass) AuthClient > setToken > removes token from storage when null is provided +@betterbase/client:test: (pass) AuthClient > setToken > calls auth state change callback with null when token is cleared +@betterbase/client:test: +@betterbase/client:test: test/errors.test.ts: +@betterbase/client:test: (pass) errors > BetterBaseError > is a subclass of Error +@betterbase/client:test: (pass) errors > BetterBaseError > preserves message +@betterbase/client:test: (pass) errors > BetterBaseError > has name property +@betterbase/client:test: (pass) errors > BetterBaseError > can be thrown and caught +@betterbase/client:test: (pass) errors > NetworkError > is a subclass of BetterBaseError +@betterbase/client:test: (pass) errors > NetworkError > has correct name +@betterbase/client:test: (pass) errors > AuthError > is a subclass of BetterBaseError +@betterbase/client:test: (pass) errors > AuthError > has correct name +@betterbase/client:test: (pass) errors > ValidationError > is a subclass of BetterBaseError +@betterbase/client:test: (pass) errors > ValidationError > has correct name +@betterbase/client:test: (pass) errors > ValidationError > error hierarchy is correct +@betterbase/client:test: +@betterbase/client:test: test/edge-cases.test.ts: +@betterbase/client:test: (pass) Client SDK — network failure handling > handles fetch throwing a network error — returns error, not throw [1.00ms] +@betterbase/client:test: (pass) Client SDK — network failure handling > error message reflects the original network error [1.00ms] +@betterbase/client:test: (pass) Client SDK — network failure handling > handles server 500 without throwing +@betterbase/client:test: (pass) Client SDK — network failure handling > handles server returning non-JSON body without throwing [1.00ms] +@betterbase/client:test: (pass) Client SDK — network failure handling > handles 404 response without throwing [3.00ms] +@betterbase/client:test: (pass) Client SDK — URL encoding > .eq() with special characters produces a parseable URL [3.00ms] +@betterbase/client:test: (pass) Client SDK — URL encoding > .in() with special characters in values produces a parseable URL +@betterbase/client:test: (pass) Client SDK — URL encoding > table name is correctly included in the request URL [5.00ms] +@betterbase/client:test: (pass) Client SDK — single-use QueryBuilder > calling execute() twice on same builder returns error on second call +@betterbase/client:test: (pass) Client SDK — single-use QueryBuilder > each client.from() call creates a fresh independent builder [4.00ms] +@betterbase/client:test: (pass) Client SDK — boundary inputs > .limit(0) sends limit=0 in request [2.00ms] +@betterbase/client:test: (pass) Client SDK — boundary inputs > .offset(0) sends offset=0 in request +@betterbase/client:test: +@betterbase/client:test: test/realtime.test.ts: +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > can be constructed without throwing +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > setToken() does not throw +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > from() returns an object with an on() method [1.00ms] +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > from().on() returns an object with a subscribe() method +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should export basic schema to SDL [46.00ms] +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > subscribe() returns an object with an unsubscribe() method [1.00ms] +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > unsubscribe() does not throw [1.00ms] +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > disconnect() does not throw +@betterbase/client:test: (pass) RealtimeClient — no WebSocket environment > callback is NOT called when disabled (no WebSocket) +@betterbase/client:test: [BetterBase] WebSocket error: ErrorEvent { +@betterbase/client:test: type: "error", +@betterbase/client:test: message: "WebSocket connection to 'ws://localhost:3000/ws' failed: Failed to connect", +@betterbase/client:test: error: error: WebSocket connection to 'ws://localhost:3000/ws' failed: Failed to connect +@betterbase/client:test: , +@betterbase/client:test: } +@betterbase/client:test: [BetterBase] WebSocket error: ErrorEvent { +@betterbase/client:test: type: "error", +@betterbase/client:test: message: "WebSocket connection to 'ws://localhost:3000/ws' failed: Failed to connect", +@betterbase/client:test: error: error: WebSocket connection to 'ws://localhost:3000/ws' failed: Failed to connect +@betterbase/client:test: , +@betterbase/client:test: } +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should include Query type in SDL [6.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should include Mutation type in SDL [2.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should include Object types in SDL [8.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should include Input types in SDL [2.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should include scalar types in SDL +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > subscribe() triggers a WebSocket connection [29.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should respect includeDescriptions option [9.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should respect useCommentSyntax option [2.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should respect sortTypes option [5.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportSDL > should include header comment +@betterbase/core:test: (pass) SDL Exporter > exportTypeSDL > should export specific Object type [1.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportTypeSDL > should export specific Input type [4.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportTypeSDL > should throw error for non-existent type +@betterbase/core:test: (pass) SDL Exporter > exportTypeSDL > should respect includeDescriptions option [1.00ms] +@betterbase/core:test: (pass) SDL Exporter > exportTypeSDL > should export scalar types +@betterbase/core:test: (pass) SDL Exporter > saveSDL > should be a function +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > subscribe() sends a subscribe message after connection opens [24.00ms] +@betterbase/core:test: (pass) SDL Exporter > SDL output validation > should produce valid SDL syntax [9.00ms] +@betterbase/core:test: (pass) SDL Exporter > SDL output validation > should properly format field arguments +@betterbase/core:test: (pass) SDL Exporter > SDL output validation > should include non-null markers for required fields [5.00ms] +@betterbase/core:test: +@betterbase/core:test: test/logger-functions.test.ts: +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > INSERT callback fires when server sends matching event [25.00ms] +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > callback does NOT fire for a different table [26.00ms] +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > wildcard event '*' receives all event types [25.00ms] +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > unsubscribe() sends unsubscribe message when last subscriber leaves [24.00ms] +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > WebSocket URL uses ws:// protocol [22.00ms] +@betterbase/client:test: (pass) RealtimeClient — with mock WebSocket > token is appended to WebSocket URL when provided [22.00ms] +@betterbase/client:test: +@betterbase/client:test: test/iac.test.ts: +@betterbase/client:test: (pass) IaC Client Integration Tests > createBetterBaseClient > should create a client with valid config +@betterbase/client:test: (pass) IaC Client Integration Tests > createBetterBaseClient > should create client and allow close +@betterbase/client:test: (pass) IaC Client Integration Tests > useQuery hook > should return default state on mount +@betterbase/client:test: (pass) IaC Client Integration Tests > useMutation hook > should return mutation interface +@betterbase/client:test: (pass) IaC Client Integration Tests > useAction hook > should return action interface +@betterbase/client:test: (pass) IaC Client Integration Tests > BetterbaseProvider > should export Provider component +@betterbase/client:test: (pass) Type exports > should export UseQueryResult type +@betterbase/client:test: (pass) Type exports > should export BetterBaseConfig type +@betterbase/client:test: +@betterbase/client:test: test/storage.test.ts: +@betterbase/core:test: (pass) Logger Functions > createRequestLogger > should create a child logger with reqId [1.00ms] +@betterbase/core:test: (pass) Logger Functions > createRequestLogger > should generate unique request IDs [2.00ms] +@betterbase/core:test: (pass) Logger Functions > createRequestLogger > should allow logging with the request logger [2.00ms] +@betterbase/core:test: (pass) Logger Functions > logSlowQuery > should not log when query is fast [1.00ms] +@betterbase/core:test: (pass) Logger Functions > logSlowQuery > should log warning when query exceeds threshold [1.00ms] +@betterbase/core:test: (pass) Logger Functions > logSlowQuery > should use default threshold of 100ms +@betterbase/core:test: (pass) Logger Functions > logSlowQuery > should log warning with custom threshold +@betterbase/core:test: (pass) Logger Functions > logSlowQuery > should handle empty query string +@betterbase/core:test: (pass) Logger Functions > logSlowQuery > should handle very long query strings +@betterbase/core:test: (pass) Logger Functions > logError > should log error with message [1.00ms] +@betterbase/core:test: (pass) Logger Functions > logError > should log error with context +@betterbase/core:test: (pass) Logger Functions > logError > should log error with empty context +@betterbase/core:test: (pass) Logger Functions > logError > should handle error without stack trace +@betterbase/core:test: (pass) Logger Functions > logError > should handle error with custom name +@betterbase/core:test: (pass) Logger Functions > logError > should handle various context values +@betterbase/core:test: (pass) Logger Functions > logSuccess > should log success with operation name +@betterbase/core:test: (pass) Logger Functions > logSuccess > should log success with metadata +@betterbase/core:test: (pass) Logger Functions > logSuccess > should log success with empty metadata [2.00ms] +@betterbase/core:test: (pass) Logger Functions > logSuccess > should handle zero duration +@betterbase/core:test: (pass) Logger Functions > logSuccess > should handle long operation names [1.00ms] +@betterbase/core:test: (pass) Logger Functions > logSuccess > should handle complex metadata +@betterbase/core:test: +@betterbase/core:test: test/storage-s3-adapter.test.ts: +@betterbase/client:test: (pass) Storage > constructor > creates Storage instance [9.00ms] +@betterbase/client:test: (pass) Storage > from > returns StorageBucketClient for specified bucket +@betterbase/client:test: (pass) Storage > from > creates bucket client with different bucket names +@betterbase/client:test: (pass) StorageBucketClient > upload > uploads file successfully and returns path and url [1.00ms] +@betterbase/client:test: (pass) StorageBucketClient > upload > uploads with custom content type [3.00ms] +@betterbase/client:test: (pass) StorageBucketClient > upload > uploads with metadata headers [2.00ms] +@betterbase/client:test: (pass) StorageBucketClient > upload > returns error when upload fails with non-ok response [19.00ms] +@betterbase/client:test: (pass) StorageBucketClient > upload > returns NetworkError when network request fails +@betterbase/client:test: (pass) StorageBucketClient > download > downloads file successfully and returns Blob +@betterbase/client:test: (pass) StorageBucketClient > download > returns error when download fails with non-ok response +@betterbase/client:test: (pass) StorageBucketClient > download > returns NetworkError when network request fails [2.00ms] +@betterbase/client:test: (pass) StorageBucketClient > getPublicUrl > returns public URL successfully +@betterbase/client:test: (pass) StorageBucketClient > getPublicUrl > returns error when getting public URL fails +@betterbase/client:test: (pass) StorageBucketClient > getPublicUrl > returns NetworkError when network request fails +@betterbase/client:test: (pass) StorageBucketClient > createSignedUrl > creates signed URL without options [1.00ms] +@betterbase/client:test: (pass) StorageBucketClient > createSignedUrl > creates signed URL with expiresIn option +@betterbase/client:test: (pass) StorageBucketClient > createSignedUrl > returns error when creating signed URL fails +@betterbase/client:test: (pass) StorageBucketClient > createSignedUrl > returns NetworkError when network request fails [3.00ms] +@betterbase/client:test: (pass) StorageBucketClient > remove > removes single file successfully +@betterbase/client:test: (pass) StorageBucketClient > remove > removes multiple files successfully +@betterbase/client:test: (pass) StorageBucketClient > remove > returns error when remove fails +@betterbase/client:test: (pass) StorageBucketClient > remove > returns NetworkError when network request fails +@betterbase/client:test: (pass) StorageBucketClient > list > lists files without prefix [2.00ms] +@betterbase/client:test: (pass) StorageBucketClient > list > lists files with prefix filter [1.00ms] +@betterbase/client:test: (pass) StorageBucketClient > list > returns empty array when no files exist +@betterbase/client:test: (pass) StorageBucketClient > list > returns error when list fails +@betterbase/client:test: (pass) StorageBucketClient > list > returns NetworkError when network request fails +@betterbase/client:test: (pass) StorageBucketClient > path encoding > properly encodes special characters in file paths +@betterbase/client:test: +@betterbase/client:test: 129 pass +@betterbase/client:test: 0 fail +@betterbase/client:test: 220 expect() calls +@betterbase/client:test: Ran 129 tests across 8 files. [844.00ms] +@betterbase/core:test: [00:16:40.076] INFO: test message +@betterbase/core:test: reqId: "FHcKR1WWQO" +@betterbase/core:test: [00:16:40.079] WARN: Slow query detected +@betterbase/core:test: query: "SELECT * FROM users WHERE id = 1" +@betterbase/core:test: duration_ms: 200 +@betterbase/core:test: threshold_ms: 100 +@betterbase/core:test: [00:16:40.079] WARN: Slow query detected +@betterbase/core:test: query: "SELECT * FROM users" +@betterbase/core:test: duration_ms: 500 +@betterbase/core:test: threshold_ms: 200 +@betterbase/core:test: [00:16:40.079] WARN: Slow query detected +@betterbase/core:test: query: "" +@betterbase/core:test: duration_ms: 200 +@betterbase/core:test: threshold_ms: 100 +@betterbase/core:test: [00:16:40.079] WARN: Slow query detected +@betterbase/core:test: query: "SELECT aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +@betterbase/core:test: duration_ms: 200 +@betterbase/core:test: threshold_ms: 100 +@betterbase/core:test: [00:16:40.081] ERROR: Test error +@betterbase/core:test: stack: "Error: Test error\n at (/workspaces/Betterbase/packages/core/test/logger-functions.test.ts:86:22)" +@betterbase/core:test: error_name: "Error" +@betterbase/core:test: [00:16:40.081] ERROR: Test error +@betterbase/core:test: stack: "Error: Test error\n at (/workspaces/Betterbase/packages/core/test/logger-functions.test.ts:92:22)" +@betterbase/core:test: error_name: "Error" +@betterbase/core:test: userId: "123" +@betterbase/core:test: operation: "test" +@betterbase/core:test: [00:16:40.081] ERROR: Test error +@betterbase/core:test: stack: "Error: Test error\n at (/workspaces/Betterbase/packages/core/test/logger-functions.test.ts:99:22)" +@betterbase/core:test: error_name: "Error" +@betterbase/core:test: [00:16:40.081] ERROR: Test error +@betterbase/core:test: error_name: "Error" +@betterbase/core:test: [00:16:40.081] ERROR: Test error +@betterbase/core:test: stack: "CustomError: Test error\n at (/workspaces/Betterbase/packages/core/test/logger-functions.test.ts:113:22)" +@betterbase/core:test: error_name: "CustomError" +@betterbase/core:test: [00:16:40.081] ERROR: Test error +@betterbase/core:test: stack: "Error: Test error\n at (/workspaces/Betterbase/packages/core/test/logger-functions.test.ts:120:22)" +@betterbase/core:test: error_name: "Error" +@betterbase/core:test: userId: "123" +@betterbase/core:test: count: 42 +@betterbase/core:test: active: true +@betterbase/core:test: data: { +@betterbase/core:test: "nested": "value" +@betterbase/core:test: } +@betterbase/core:test: [00:16:40.082] INFO: Operation completed: test_operation +@betterbase/core:test: operation: "test_operation" +@betterbase/core:test: duration_ms: 100 +@betterbase/core:test: [00:16:40.082] INFO: Operation completed: test_operation +@betterbase/core:test: operation: "test_operation" +@betterbase/core:test: duration_ms: 100 +@betterbase/core:test: records: 10 +@betterbase/core:test: userId: "123" +@betterbase/core:test: [00:16:40.082] INFO: Operation completed: test_operation +@betterbase/core:test: operation: "test_operation" +@betterbase/core:test: duration_ms: 100 +@betterbase/core:test: [00:16:40.083] INFO: Operation completed: test_operation +@betterbase/core:test: operation: "test_operation" +@betterbase/core:test: duration_ms: 0 +@betterbase/core:test: [00:16:40.084] INFO: Operation completed: very_long_operation_name_that_does_something +@betterbase/core:test: operation: "very_long_operation_name_that_does_something" +@betterbase/core:test: duration_ms: 500 +@betterbase/core:test: [00:16:40.084] INFO: Operation completed: test +@betterbase/core:test: operation: "test" +@betterbase/core:test: duration_ms: 100 +@betterbase/core:test: users: [ +@betterbase/core:test: "user1", +@betterbase/core:test: "user2" +@betterbase/core:test: ] +@betterbase/core:test: count: 2 +@betterbase/core:test: data: { +@betterbase/core:test: "key": "value" +@betterbase/core:test: } +@betterbase/cli:test: (pass) RouteScanner > extracts hono routes with auth and schemas (GET + POST) [53.00ms] +@betterbase/cli:test: (pass) RouteScanner > extracts PATCH routes [9.00ms] +@betterbase/cli:test: (pass) RouteScanner > extracts DELETE routes [1.00ms] +@betterbase/cli:test: (pass) RouteScanner > extracts public routes with no auth [4.00ms] +@betterbase/cli:test: (pass) RouteScanner > handles malformed decorators / syntax errors in route definitions [4.00ms] +@betterbase/cli:test: (pass) RouteScanner > discovers routes in nested directory groups [7.00ms] +@betterbase/cli:test: (pass) RouteScanner > extracts routes with multiple middleware [2.00ms] +@betterbase/cli:test: (pass) RouteScanner > extracts routes with query parameter validation [5.00ms] +@betterbase/cli:test: (pass) RouteScanner > handles mixed protected and public routes in the same file [7.00ms] +@betterbase/cli:test: (pass) RouteScanner > handles empty route files with no handlers [2.00ms] +@betterbase/cli:test: (pass) RouteScanner > PATCH and DELETE routes with both auth and no-auth variants [9.00ms] +@betterbase/cli:test: (pass) RouteScanner > No-auth routes (routes without requireAuth or optionalAuth) [9.00ms] +@betterbase/cli:test: (pass) RouteScanner > Malformed decorators (missing parentheses, invalid syntax) [7.00ms] +@betterbase/cli:test: (pass) RouteScanner > Nested route groups (app.group('/api', ...) with nested routes) [13.00ms] +@betterbase/cli:test: (pass) RouteScanner > Mixed public/protected in same file (detailed) [4.00ms] +@betterbase/cli:test: (pass) RouteScanner > Routes with CORS and other middleware that might confuse scanner [6.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/graphql-type-map.test.ts: +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Integer types > should map integer to Int +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Integer types > should map int to Int +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Integer types > should map smallint to Int +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Integer types > should map bigint to Int +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Integer types > should handle uppercase INTEGER +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Float types > should map real to Float +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Float types > should map double to Float +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Float types > should map float to Float +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Float types > should map numeric to Float +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Float types > should map decimal to Float +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Float types > should handle case insensitivity for float types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Boolean types > should map boolean to Boolean +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Boolean types > should map bool to Boolean +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Boolean types > should handle case insensitivity for boolean types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > String types > should map text to String +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > String types > should map varchar to String +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > String types > should map char to String +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > String types > should handle case insensitivity for string types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > UUID types > should map uuid to ID +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > UUID types > should handle case insensitivity for uuid +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > DateTime types > should map timestamp to DateTime +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > DateTime types > should map timestamptz to DateTime +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > DateTime types > should map datetime to DateTime +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > DateTime types > should map date to DateTime +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > DateTime types > should handle case insensitivity for datetime types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > JSON types > should map json to JSON +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > JSON types > should map jsonb to JSON +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > JSON types > should handle case insensitivity for json types [3.00ms] +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Binary types > should map blob to String +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Binary types > should map bytea to String +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Binary types > should handle case insensitivity for binary types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Default fallback > should return String for unknown types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Default fallback > should return String for empty string +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Default fallback > should return String for custom types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Edge cases > should handle types with numbers (fallback to String) +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Edge cases > should handle types with underscores (fallback to String) +@betterbase/cli:test: (pass) CLI GraphQL Type Map - drizzleTypeToGraphQL > Edge cases > should handle types with spaces +@betterbase/cli:test: (pass) CLI GraphQL Type Map - Integration Tests > should correctly map a complete PostgreSQL table schema +@betterbase/cli:test: (pass) CLI GraphQL Type Map - Integration Tests > should correctly map a complete MySQL table schema +@betterbase/cli:test: (pass) CLI GraphQL Type Map - Integration Tests > should correctly map a complete SQLite table schema +@betterbase/cli:test: (pass) CLI GraphQL Type Map - Integration Tests > should correctly map a user profile table schema +@betterbase/cli:test: (pass) CLI GraphQL Type Map - Integration Tests > should correctly map an e-commerce products table schema +@betterbase/cli:test: (pass) CLI GraphQL Type Map - typeMap completeness > should have mappings for all PostgreSQL types [6.00ms] +@betterbase/cli:test: (pass) CLI GraphQL Type Map - typeMap completeness > should have mappings for all MySQL types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - typeMap completeness > should have mappings for all SQLite types +@betterbase/cli:test: (pass) CLI GraphQL Type Map - Source File Comparison > should match the typeMap in src/commands/graphql.ts exactly [6.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/init.test.ts: +@betterbase/cli:test: (pass) runInitCommand > creates project with project name +@betterbase/cli:test: (pass) runInitCommand > InitCommandOptions type is correct +@betterbase/cli:test: +@betterbase/cli:test: test/iac-commands.test.ts: +@betterbase/cli:test: (pass) runIacAnalyze > should analyze queries and return results [5.00ms] +@betterbase/cli:test: (pass) runIacAnalyze > should detect N+1 query patterns [3.00ms] +@betterbase/cli:test: (pass) runIacAnalyze > should detect missing index usage +@betterbase/cli:test: (pass) runIacAnalyze > should output results in json format [3.00ms] +@betterbase/cli:test: (pass) runIacAnalyze > should calculate complexity correctly [2.00ms] +@betterbase/cli:test: (pass) runIacAnalyze > should detect N+1 query patterns using for loops [5.00ms] +@betterbase/cli:test: (pass) runIacAnalyze > should detect manual join patterns [3.00ms] +@betterbase/cli:test: ◆ Analyzing queries... +@betterbase/cli:test: (pass) runIacAnalyze > should handle multiple query files [3.00ms] +@betterbase/cli:test: (pass) runIacAnalyze > should throw when queries directory is missing +@betterbase/cli:test: (pass) runIacAnalyze > should support nested betterbase directory structure [1.00ms] +@betterbase/cli:test: (pass) runIacExport > should handle json format export [5.00ms] +@betterbase/cli:test: (pass) runIacExport > should handle sql format export +@betterbase/cli:test: (pass) runIacExport > should use default format when not specified [1.00ms] +@betterbase/cli:test: (pass) runIacExport > should handle output path correctly +@betterbase/cli:test: (pass) runIacExport > should handle table-specific export +@betterbase/cli:test: (pass) runIacExport > should accept absolute output paths [5.00ms] +@betterbase/cli:test: (pass) runIacExport > should accept custom table names with special characters +@betterbase/cli:test: (pass) runIacExport > should log export initialization success +@betterbase/cli:test: (pass) runIacExport > should handle nested output directories [1.00ms] +@betterbase/cli:test: (pass) runIacImport > should detect json input files [2.00ms] +@betterbase/cli:test: (pass) runIacImport > should detect sql input files [1.00ms] +@betterbase/cli:test: (pass) runIacImport > should respect dry-run flag +@betterbase/cli:test: (pass) runIacImport > should default dry-run to false [2.00ms] +@betterbase/cli:test: (pass) runIacImport > should error on missing input file +@betterbase/cli:test: (pass) runIacImport > should handle table-specific imports [3.00ms] +@betterbase/cli:test: (pass) runIacImport > should handle complex json data structures +@betterbase/cli:test: (pass) runIacImport > should accept absolute input paths [1.00ms] +@betterbase/cli:test: (pass) runIacImport > should log import success after processing [1.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex > should convert Convex schema to BetterBase schema [6.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: (pass) runMigrateFromConvex > should convert Convex queries to BetterBase queries [6.00ms] +@betterbase/cli:test: (pass) runMigrateFromConvex > should convert Convex mutations to BetterBase mutations [1.00ms] +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted 1 mutations +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted 1 actions +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex > should convert Convex actions to BetterBase actions [4.00ms] +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: (pass) runMigrateFromConvex > should create proper directory structure in output [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: (pass) runMigrateFromConvex > should replace Convex imports with BetterBase imports in functions [2.00ms] +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex > should handle schema with no tables [2.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex > should generate migration report JSON file [2.00ms] +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex > should generate migration report markdown file [2.00ms] +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 1 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 1 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex > should detect httpAction as blocker [4.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex > should detect cronJobs as blocker [1.00ms] +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: (pass) runMigrateFromConvex > should throw when input directory does not exist +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 1 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 1 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: ✅ Converted 1 mutations +@betterbase/cli:test: ✅ Converted 1 actions +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/convex-migration-test/convex... +@betterbase/cli:test: Output will be in /tmp/convex-migration-test/migrated +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: (pass) runMigrateFromConvex > should count converted files accurately in report [17.00ms] +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/convex-migration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/convex-migration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/core:test: (pass) S3 Adapter > createS3Adapter - S3 Provider > should create S3 adapter with valid S3 config [48.00ms] +@betterbase/core:test: (pass) S3 Adapter > createS3Adapter - S3 Provider > should return StorageAdapter interface [1.00ms] +@betterbase/cli:test: (pass) runMigrateFromConvex > should convert v.string(), v.number(), v.boolean() validators [5.00ms] +@betterbase/cli:test: (pass) Integration Tests > should set up test project structure +@betterbase/cli:test: (pass) Integration Tests > should create sample query file [1.00ms] +@betterbase/cli:test: (pass) Integration Tests > should create sample mutation file [1.00ms] +@betterbase/cli:test: (pass) Integration Tests > should create sample schema file +@betterbase/core:test: (pass) S3 Adapter > S3 Adapter - Get Public URL > should generate correct S3 public URL format [2.00ms] +@betterbase/core:test: (pass) S3 Adapter > S3 Adapter - Get Public URL > should handle different regions [2.00ms] +@betterbase/core:test: (pass) S3 Adapter > S3 Adapter - Get Public URL > should handle west regions +@betterbase/core:test: (pass) S3 Adapter > S3 Adapter - Get Public URL > should handle nested paths [1.00ms] +@betterbase/cli:test: (pass) Integration Tests > should run full analyze-export-import workflow [3.00ms] +@betterbase/core:test: (pass) S3 Adapter > S3 Adapter - Get Public URL > should handle special characters in path [2.00ms] +@betterbase/core:test: (pass) S3 Adapter > R2 Provider > should create R2 adapter [2.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/iac-integration-test/convex... +@betterbase/core:test: (pass) S3 Adapter > R2 Provider > should generate correct R2 public URL +@betterbase/cli:test: Output will be in /tmp/iac-integration-test/migrated +@betterbase/cli:test: (pass) Integration Tests > should handle Convex migration with blocker issues [4.00ms] +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/core:test: (pass) S3 Adapter > R2 Provider > should use custom endpoint if provided [2.00ms] +@betterbase/core:test: (pass) S3 Adapter > Backblaze Provider > should create Backblaze adapter [1.00ms] +@betterbase/core:test: (pass) S3 Adapter > Backblaze Provider > should generate correct Backblaze public URL +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: ✅ Converted 1 mutations +@betterbase/core:test: (pass) S3 Adapter > Backblaze Provider > should handle different Backblaze regions [1.00ms] +@betterbase/cli:test: ✅ Converted 1 actions +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/iac-integration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/iac-integration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/core:test: (pass) S3 Adapter > MinIO Provider > should create MinIO adapter with default settings +@betterbase/cli:test: (pass) Integration Tests > should complete full Convex migration with all file types [4.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/iac-integration-test/convex... +@betterbase/core:test: (pass) S3 Adapter > MinIO Provider > should create MinIO adapter with custom port [1.00ms] +@betterbase/cli:test: Output will be in /tmp/iac-integration-test/migrated +@betterbase/core:test: (pass) S3 Adapter > MinIO Provider > should generate correct MinIO public URL with SSL (default) [1.00ms] +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/iac-integration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/iac-integration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/core:test: (pass) S3 Adapter > MinIO Provider > should generate correct MinIO public URL without SSL [1.00ms] +@betterbase/cli:test: (pass) Integration Tests > should convert edge cases: optional fields and arrays [2.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/iac-integration-test/convex... +@betterbase/core:test: (pass) S3 Adapter > MinIO Provider > should use custom port without SSL [1.00ms] +@betterbase/core:test: (pass) S3 Adapter > MinIO Provider > should default to port 9000 without SSL [1.00ms] +@betterbase/cli:test: Output will be in /tmp/iac-integration-test/migrated +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: (pass) Integration Tests > should preserve function logic during conversion [3.00ms] +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/iac-integration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/iac-integration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: Migrating Convex project from /tmp/iac-integration-test/convex... +@betterbase/cli:test: Output will be in /tmp/iac-integration-test/migrated +@betterbase/core:test: (pass) S3 Adapter > Adapter Interface Compliance > S3 adapter should have all required methods +@betterbase/core:test: (pass) S3 Adapter > Adapter Interface Compliance > R2 adapter should have all required methods [2.00ms] +@betterbase/core:test: (pass) S3 Adapter > Config validation > should accept minimal S3 config +@betterbase/cli:test: ✅ Converted 1 querys +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/iac-integration-test/migrated +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/iac-integration-test/migrated/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: (pass) Integration Tests > should not modify original Convex source files [2.00ms] +@betterbase/core:test: (pass) S3 Adapter > Config validation > should accept full R2 config with endpoint [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/dev.test.ts: +@betterbase/core:test: (pass) S3 Adapter > Config validation > should accept full Backblaze config with endpoint [1.00ms] +@betterbase/core:test: (pass) S3 Adapter > Config validation > should accept full MinIO config [1.00ms] +@betterbase/core:test: +@betterbase/core:test: test/webhook-functions.test.ts: +@betterbase/cli:test: (pass) runDevCommand > is a callable async function [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-8d4d331a +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > returns a cleanup function [4.00ms] +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-0624e2ab +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > cleanup function resolves without error [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-179c1697 +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: +@betterbase/cli:test: (pass) runDevCommand > starts ProcessManager when invoked [3.00ms] +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: (pass) runDevCommand > starts DevWatcher when invoked +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-ed37a2e0 +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/core:test: (pass) Webhook Functions > initializeWebhooks > should return null when no webhooks configured [3.00ms] +@betterbase/core:test: (pass) Webhook Functions > initializeWebhooks > should return null when webhooks array is empty +@betterbase/core:test: (pass) Webhook Functions > initializeWebhooks > should skip disabled webhooks +@betterbase/core:test: [webhooks] No webhooks configured +@betterbase/cli:test: Project root /tmp/bb-test-07ee7405 +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > skips IAC sync and generate when no betterbase/ directory [2.00ms] +@betterbase/core:test: [webhooks] Skipping webhook test-webhook: URL and secret must be environment variable references +@betterbase/core:test: [webhooks] No webhooks configured +@betterbase/core:test: (pass) Webhook Functions > initializeWebhooks > should skip webhooks with invalid env var references [1.00ms] +@betterbase/core:test: [webhooks] Skipping webhook test-webhook: MISSING_WEBHOOK_URL environment variable is not set +@betterbase/core:test: [webhooks] Active: 1 webhook(s) configured +@betterbase/core:test: [webhooks] Delivery logging: enabled (in-memory only) +@betterbase/core:test: [webhooks] Active: 2 webhook(s) configured +@betterbase/core:test: [webhooks] Delivery logging: enabled (in-memory only) +@betterbase/core:test: [webhooks] No webhooks initialized. Missing environment variables: MISSING_WEBHOOK_URL +@betterbase/core:test: (pass) Webhook Functions > initializeWebhooks > should skip webhooks with missing env vars [2.00ms] +@betterbase/core:test: (pass) Webhook Functions > initializeWebhooks > should initialize webhook with valid config and env vars +@betterbase/core:test: (pass) Webhook Functions > initializeWebhooks > should handle multiple webhooks +@betterbase/core:test: (pass) Webhook Functions > connectToRealtime > should connect dispatcher to realtime emitter [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: (pass) runDevCommand > calls IAC sync and generate when betterbase/ directory exists [1.00ms] +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-b018e844 +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ IaC layer detected — betterbase/ will be watched for schema and function changes. +@betterbase/cli:test: ◆ [iac] Running initial sync... +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: ⚠ [iac] Initial sync skipped: Schema parse error +@betterbase/cli:test: Project root /tmp/bb-test-0b98c82c +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: (pass) runDevCommand > does not crash when IAC sync throws an error [2.00ms] +@betterbase/cli:test: +@betterbase/cli:test: ⚠ [iac] Initial generate skipped: Generation failure +@betterbase/cli:test: (pass) runDevCommand > does not crash when IAC generate throws an error +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ IaC layer detected — betterbase/ will be watched for schema and function changes. +@betterbase/cli:test: ◆ [iac] Running initial sync... +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-a4b99a4a +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ IaC layer detected — betterbase/ will be watched for schema and function changes. +@betterbase/cli:test: ◆ [iac] Running initial sync... +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > enables query log when QUERY_LOG=true [4.00ms] +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-5f79426f +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-705abb25 +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > does not enable query log when QUERY_LOG is unset [5.00ms] +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-036b3c78 +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > cleanup stops ProcessManager and DevWatcher [3.00ms] +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /nonexistent/path/12345 +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > accepts projectRoot with a nonexistent path gracefully +@betterbase/cli:test: +@betterbase/cli:test: bb dev — watching for changes +@betterbase/cli:test: +@betterbase/cli:test: Project root /tmp/bb-test-026115fd +@betterbase/cli:test: Server URL http://localhost:3000 +@betterbase/cli:test: Dashboard http://localhost:3000/admin +@betterbase/cli:test: +@betterbase/cli:test: Press Ctrl+C to stop +@betterbase/cli:test: +@betterbase/cli:test: ◆ [dev] Shutting down... +@betterbase/cli:test: (pass) runDevCommand > generates context on startup [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/context-generator.test.ts: +@betterbase/cli:test: 34 | ); +@betterbase/cli:test: 35 | +@betterbase/cli:test: 36 | const generator = new ContextGenerator(); +@betterbase/cli:test: 37 | const context = await generator.generate(root); +@betterbase/cli:test: 38 | +@betterbase/cli:test: 39 | expect(context.tables.users).toBeDefined(); +@betterbase/cli:test: ^ +@betterbase/cli:test: TypeError: undefined is not an object (evaluating 'context.tables.users') +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/context-generator.test.ts:39:19) +@betterbase/cli:test: (fail) ContextGenerator > creates .betterbase-context.json from schema and routes [3.00ms] +@betterbase/cli:test: 65 | export const users = sqliteTable('users', { id: text('id').primaryKey() }); +@betterbase/cli:test: 66 | `, +@betterbase/cli:test: 67 | ); +@betterbase/cli:test: 68 | +@betterbase/cli:test: 69 | const context = await new ContextGenerator().generate(root); +@betterbase/cli:test: 70 | expect(context.routes).toEqual({}); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toEqual(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: {} +@betterbase/cli:test: Received: undefined +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/context-generator.test.ts:70:27) +@betterbase/cli:test: (fail) ContextGenerator > handles missing routes directory with empty routes +@betterbase/cli:test: 83 | mkdirSync(path.join(root, "src/db"), { recursive: true }); +@betterbase/cli:test: 84 | mkdirSync(path.join(root, "src/routes"), { recursive: true }); +@betterbase/cli:test: 85 | writeFileSync(path.join(root, "src/db/schema.ts"), "export {};\n"); +@betterbase/cli:test: 86 | +@betterbase/cli:test: 87 | const context = await new ContextGenerator().generate(root); +@betterbase/cli:test: 88 | expect(context.tables).toEqual({}); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toEqual(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: {} +@betterbase/cli:test: Received: undefined +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/context-generator.test.ts:88:27) +@betterbase/cli:test: (fail) ContextGenerator > handles empty schema file with empty tables [1.00ms] +@betterbase/cli:test: 98 | try { +@betterbase/cli:test: 99 | mkdirSync(path.join(root, "src/routes"), { recursive: true }); +@betterbase/cli:test: 100 | writeFileSync(path.join(root, "src/routes/index.ts"), "export {};\n"); +@betterbase/cli:test: 101 | +@betterbase/cli:test: 102 | const context = await new ContextGenerator().generate(root); +@betterbase/cli:test: 103 | expect(context.tables).toEqual({}); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toEqual(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: {} +@betterbase/cli:test: Received: undefined +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/context-generator.test.ts:103:27) +@betterbase/cli:test: (fail) ContextGenerator > handles missing schema file with empty tables [2.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/output-snapshots.test.ts: +@betterbase/cli:test: (pass) output-snapshots: iac analyze > produces expected JSON output on empty project (snapshot) [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/provider-prompts.test.ts: +@betterbase/core:test: (pass) Webhook Functions > connectToRealtime > should handle db:change events [52.00ms] +@betterbase/core:test: (pass) Webhook Functions > connectToRealtime > should handle db:insert events [54.00ms] +@betterbase/core:test: (pass) Webhook Functions > connectToRealtime > should handle db:update events [51.00ms] +@betterbase/core:test: (pass) Webhook Functions > connectToRealtime > should handle db:delete events [52.00ms] +@betterbase/core:test: {"type":"webhook_realtime_integration_error","error":"Dispatch failed","timestamp":"2026-05-02T00:16:40.980Z"} +@betterbase/core:test: (pass) Webhook Functions > connectToRealtime > should handle dispatch errors gracefully [52.00ms] +@betterbase/core:test: +@betterbase/core:test: test/vector.test.ts: +@betterbase/cli:test: (pass) Provider prompts > promptForProvider > is a function that can be imported [34.00ms] +@betterbase/cli:test: (pass) Provider prompts > generateEnvContent > generates env content for neon provider [11.00ms] +@betterbase/cli:test: (pass) Provider prompts > generateEnvContent > generates env content for turso provider +@betterbase/cli:test: (pass) Provider prompts > generateEnvContent > generates env content for planetscale provider +@betterbase/cli:test: (pass) Provider prompts > generateEnvContent > generates env content for supabase provider +@betterbase/cli:test: (pass) Provider prompts > generateEnvContent > generates env content for postgres provider +@betterbase/cli:test: (pass) Provider prompts > generateEnvContent > handles empty env vars +@betterbase/cli:test: (pass) Provider prompts > generateEnvExampleContent > generates env example for neon provider +@betterbase/cli:test: (pass) Provider prompts > generateEnvExampleContent > generates env example for turso provider [1.00ms] +@betterbase/cli:test: (pass) Provider prompts > generateEnvExampleContent > generates env example for all provider types [1.00ms] +@betterbase/cli:test: (pass) Provider prompts > promptForStorage > is a function that can be imported +@betterbase/cli:test: (pass) Provider prompts > ProviderPromptResult interface > defines providerType and envVars properties +@betterbase/cli:test: +@betterbase/cli:test: test/error-messages.test.ts: +@betterbase/cli:test: (pass) Error message quality > Migrate error messages > migrate error includes backup path and restore command +@betterbase/cli:test: (pass) Error message quality > Migrate error messages > includes helpful restore instructions in error messages +@betterbase/cli:test: (pass) Error message quality > Generate CRUD error messages > generate crud error lists available tables when table not found [49.00ms] +@betterbase/cli:test: (pass) Error message quality > Generate CRUD error messages > provides clear error when schema file is missing [1.00ms] +@betterbase/cli:test: (pass) Error message quality > Error message formatting > includes error details in migrate failure +@betterbase/cli:test: (pass) Error message quality > Error message formatting > includes connection error details +@betterbase/cli:test: +@betterbase/cli:test: test/logger.test.ts: +@betterbase/cli:test: (pass) Logger utility > info method > logs informational messages to console.log [7.00ms] +@betterbase/cli:test: (pass) Logger utility > info method > handles empty string message +@betterbase/cli:test: (pass) Logger utility > info method > handles special characters in message +@betterbase/cli:test: 73 | logger.info("info test"); +@betterbase/cli:test: 74 | expect(spyLog).toHaveBeenCalledTimes(1); +@betterbase/cli:test: 75 | const raw = spyLog.mock.calls[0][0] as string; +@betterbase/cli:test: 76 | const stripped = stripAnsi(raw); +@betterbase/cli:test: 77 | expect(stripped).toContain(`${logger.sym.info} info test`); +@betterbase/cli:test: 78 | expect(raw).not.toBe(stripped); // ANSI codes present +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).not.toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: not "◆ info test" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:78:20) +@betterbase/cli:test: (fail) Logger utility > info method > calls console.log with info symbol prefix [1.00ms] +@betterbase/cli:test: (pass) Logger utility > warn method > logs warning messages to console.warn +@betterbase/cli:test: (pass) Logger utility > warn method > handles empty string message +@betterbase/cli:test: 99 | logger.warn("warn test"); +@betterbase/cli:test: 100 | expect(spyWarn).toHaveBeenCalledTimes(1); +@betterbase/cli:test: 101 | const raw = spyWarn.mock.calls[0][0] as string; +@betterbase/cli:test: 102 | const stripped = stripAnsi(raw); +@betterbase/cli:test: 103 | expect(stripped).toContain(`${logger.sym.warn} warn test`); +@betterbase/cli:test: 104 | expect(raw).not.toBe(stripped); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).not.toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: not "⚠ warn test" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:104:20) +@betterbase/cli:test: (fail) Logger utility > warn method > calls console.warn with warning symbol prefix +@betterbase/cli:test: (pass) Logger utility > error method > logs error messages to console.error +@betterbase/cli:test: (pass) Logger utility > error method > handles empty string message +@betterbase/cli:test: (pass) Logger utility > error method > handles error objects as messages +@betterbase/cli:test: (pass) Logger utility > error method > prints hint on second line when hint is provided +@betterbase/cli:test: (pass) Logger utility > error method > does not print hint line when no hint is provided +@betterbase/cli:test: 145 | logger.error("error test"); +@betterbase/cli:test: 146 | expect(spyError).toHaveBeenCalledTimes(1); +@betterbase/cli:test: 147 | const raw = spyError.mock.calls[0][0] as string; +@betterbase/cli:test: 148 | const stripped = stripAnsi(raw); +@betterbase/cli:test: 149 | expect(stripped).toContain(`${logger.sym.error} error test`); +@betterbase/cli:test: 150 | expect(raw).not.toBe(stripped); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).not.toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: not "✗ error test" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:150:20) +@betterbase/cli:test: (fail) Logger utility > error method > calls console.error with error symbol prefix and colored message +@betterbase/cli:test: 152 | +@betterbase/cli:test: 153 | it("error shows hint when provided, with dim styling", () => { +@betterbase/cli:test: 154 | logger.error("Oops", "Run with --debug"); +@betterbase/cli:test: 155 | expect(spyError).toHaveBeenCalledTimes(2); +@betterbase/cli:test: 156 | const hintRaw = spyError.mock.calls[1][0] as string; +@betterbase/cli:test: 157 | expect(hintRaw).toContain("\x1b[2m"); // dim ANSI +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toContain(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected to contain: "\u001B[2m" +@betterbase/cli:test: Received: " Run with --debug" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:157:20) +@betterbase/cli:test: (fail) Logger utility > error method > error shows hint when provided, with dim styling +@betterbase/cli:test: (pass) Logger utility > success method > logs success messages to console.log [6.00ms] +@betterbase/cli:test: (pass) Logger utility > success method > handles empty string message +@betterbase/cli:test: 179 | logger.success("success test"); +@betterbase/cli:test: 180 | expect(spyLog).toHaveBeenCalledTimes(1); +@betterbase/cli:test: 181 | const raw = spyLog.mock.calls[0][0] as string; +@betterbase/cli:test: 182 | const stripped = stripAnsi(raw); +@betterbase/cli:test: 183 | expect(stripped).toContain(`${logger.sym.success} success test`); +@betterbase/cli:test: 184 | expect(raw).not.toBe(stripped); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).not.toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: not "✓ success test" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:184:20) +@betterbase/cli:test: (fail) Logger utility > success method > calls console.log with success symbol prefix [3.00ms] +@betterbase/cli:test: (pass) Logger utility > dim method > logs dimmed message to console.log +@betterbase/cli:test: (pass) Logger utility > dim method > handles empty string [2.00ms] +@betterbase/cli:test: (pass) Logger utility > step method > logs step with badge to console.log +@betterbase/cli:test: (pass) Logger utility > section method > prints blank line, bold title, and dim separator +@betterbase/cli:test: (pass) Logger utility > section method > truncates separator at 60 chars for long titles +@betterbase/cli:test: (pass) Logger utility > section method > handles empty title [1.00ms] +@betterbase/cli:test: (pass) Logger utility > section method > outputs title and separator line correctly +@betterbase/cli:test: 255 | expect(spyLog).toHaveBeenCalledTimes(1); +@betterbase/cli:test: 256 | const raw = spyLog.mock.calls[0][0] as string; +@betterbase/cli:test: 257 | const stripped = stripAnsi(raw); +@betterbase/cli:test: 258 | const expected = ` ${"Name".padEnd(22)} my-project`; +@betterbase/cli:test: 259 | expect(stripped).toBe(expected); +@betterbase/cli:test: 260 | expect(raw).not.toBe(stripped); // value is colored +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).not.toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: not " Name my-project" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:260:20) +@betterbase/cli:test: (fail) Logger utility > keyValue method > prints indented key-value pair with padded key and cyan value +@betterbase/cli:test: (pass) Logger utility > keyValue method > obscures secret values with dots [1.00ms] +@betterbase/cli:test: (pass) Logger utility > keyValue method > pads key to exactly 22 characters +@betterbase/cli:test: 278 | }); +@betterbase/cli:test: 279 | +@betterbase/cli:test: 280 | it("value is colored cyan", () => { +@betterbase/cli:test: 281 | logger.keyValue("Env", "production"); +@betterbase/cli:test: 282 | const raw = spyLog.mock.calls[0][0] as string; +@betterbase/cli:test: 283 | expect(raw).toContain("\x1b[36m"); // cyan open +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toContain(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected to contain: "\u001B[36m" +@betterbase/cli:test: Received: " Env production" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:283:16) +@betterbase/cli:test: (fail) Logger utility > keyValue method > value is colored cyan +@betterbase/cli:test: (pass) Logger utility > tree method > prints tree items with branch characters [4.00ms] +@betterbase/cli:test: (pass) Logger utility > tree method > uses treeLast for single item [1.00ms] +@betterbase/cli:test: (pass) Logger utility > tree method > handles empty array +@betterbase/cli:test: (pass) Logger utility > tree method > outputs tree lines with proper indentation and symbols +@betterbase/cli:test: (pass) Logger utility > blank method > prints a single newline +@betterbase/cli:test: (pass) Logger utility > blank method > calls console.log with empty string [2.00ms] +@betterbase/cli:test: (pass) Logger utility > box method > prints a box with title and key-value lines +@betterbase/cli:test: (pass) Logger utility > box method > handles empty lines array +@betterbase/cli:test: (pass) Logger utility > box method > outputs multi-line box with borders +@betterbase/cli:test: (pass) Logger utility > banner method > prints app name, version, and tagline [1.00ms] +@betterbase/cli:test: (pass) Logger utility > done method > prints elapsed time with success symbol +@betterbase/cli:test: (pass) Logger utility > done method > prints custom message when provided +@betterbase/cli:test: (pass) Logger utility > done method > prepends newline before output +@betterbase/cli:test: (pass) Logger utility > badge method > returns colored badge string for green +@betterbase/cli:test: (pass) Logger utility > badge method > returns colored badge string for red +@betterbase/cli:test: (pass) Logger utility > badge method > returns colored badge string for yellow +@betterbase/cli:test: (pass) Logger utility > badge method > returns colored badge string for blue +@betterbase/cli:test: (pass) Logger utility > badge method > returns colored badge string for dim +@betterbase/cli:test: 469 | }); +@betterbase/cli:test: 470 | +@betterbase/cli:test: 471 | it("contains ANSI color codes (not plain text)", () => { +@betterbase/cli:test: 472 | const result = logger.badge("PASS", "green"); +@betterbase/cli:test: 473 | expect(stripAnsi(result)).toBe(" PASS "); +@betterbase/cli:test: 474 | expect(result.length).toBeGreaterThan(" PASS ".length); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toBeGreaterThan(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: > 6 +@betterbase/cli:test: Received: 6 +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/logger.test.ts:474:26) +@betterbase/cli:test: (fail) Logger utility > badge method > contains ANSI color codes (not plain text) +@betterbase/cli:test: (pass) Logger utility > badge method > returns correct colored badge for each color +@betterbase/cli:test: (pass) Logger utility > sym constants > has success symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > has error symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > has warn symbol [3.00ms] +@betterbase/cli:test: (pass) Logger utility > sym constants > has info symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > has arrow symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > has bullet symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > has tree symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > has treeLast symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > has dot symbol +@betterbase/cli:test: (pass) Logger utility > sym constants > all sym values are non-empty strings [2.00ms] +@betterbase/cli:test: (pass) Logger utility > sym constants > sym has correct emoji values when UNICODE is true +@betterbase/cli:test: (pass) Logger utility > sym constants > sym has ASCII fallbacks when UNICODE is false +betterbase-base-template:test: <-- GET /health +@betterbase/cli:test: (pass) Logger utility > logging with different message types > handles string messages [1.00ms] +@betterbase/cli:test: (pass) Logger utility > logging with different message types > handles multiline messages +@betterbase/cli:test: (pass) Logger utility > logging with different message types > handles messages with quotes +@betterbase/cli:test: (pass) Logger utility > logging with different message types > handles unicode characters +@betterbase/cli:test: +@betterbase/cli:test: test/prompts.test.ts: +@betterbase/core:test: (pass) vector/types > DEFAULT_EMBEDDING_CONFIGS has correct providers +@betterbase/core:test: (pass) vector/types > DEFAULT_EMBEDDING_CONFIGS.openai has correct defaults +betterbase-base-template:test: --> GET /health 200 27ms +@betterbase/core:test: (pass) vector/embeddings - validateEmbeddingDimensions > validates correct dimensions [7.00ms] +@betterbase/core:test: (pass) vector/embeddings - validateEmbeddingDimensions > throws on dimension mismatch +@betterbase/core:test: (pass) vector/embeddings - normalizeVector > normalizes a vector to unit length +@betterbase/core:test: (pass) vector/embeddings - normalizeVector > handles zero vector +@betterbase/core:test: (pass) vector/embeddings - normalizeVector > preserves direction +@betterbase/core:test: (pass) vector/embeddings - computeCosineSimilarity > returns 1 for identical vectors +@betterbase/core:test: (pass) vector/embeddings - computeCosineSimilarity > returns 0 for orthogonal vectors +@betterbase/core:test: (pass) vector/embeddings - computeCosineSimilarity > returns -1 for opposite vectors +@betterbase/core:test: (pass) vector/embeddings - computeCosineSimilarity > throws for different dimension vectors +@betterbase/core:test: (pass) vector/embeddings - createEmbeddingConfig > creates config with defaults [1.00ms] +@betterbase/core:test: (pass) vector/embeddings - createEmbeddingConfig > overrides defaults with provided values +@betterbase/core:test: (pass) vector/embeddings - createEmbeddingConfig > handles cohere provider +@betterbase/core:test: (pass) vector/search - VECTOR_OPERATORS > has correct cosine operator +@betterbase/core:test: (pass) vector/search - VECTOR_OPERATORS > has correct euclidean operator +@betterbase/core:test: (pass) vector/search - VECTOR_OPERATORS > has correct inner product operator +@betterbase/core:test: (pass) vector/search - validateEmbedding > validates valid embedding +@betterbase/core:test: (pass) vector/search - validateEmbedding > throws for non-array +@betterbase/core:test: (pass) vector/search - validateEmbedding > throws for empty array +@betterbase/core:test: (pass) vector/search - validateEmbedding > throws for non-numeric values +@betterbase/core:test: (pass) vector/search - validateEmbedding > throws for NaN values +@betterbase/core:test: (pass) vector/search - validateEmbedding > throws for Infinity +@betterbase/core:test: (pass) vector/search - embeddingToSql > converts array to SQL vector literal +@betterbase/core:test: (pass) vector/search - embeddingToSql > handles empty-ish numbers +@betterbase/core:test: (pass) vector/search - buildVectorSearchQuery > builds basic query +@betterbase/core:test: (pass) vector/search - buildVectorSearchQuery > applies limit +betterbase-base-template:test: (pass) health endpoint > GET /health returns 200 with healthy status [92.00ms] +betterbase-base-template:test: +betterbase-base-template:test: test/crud.test.ts: +@betterbase/core:test: (pass) vector/search - buildVectorSearchQuery > applies filter [5.00ms] +@betterbase/cli:test: (pass) Prompt utilities > text prompt > validates message is required [5.00ms] +@betterbase/core:test: (pass) vector/search - buildVectorSearchQuery > uses correct operator for cosine +@betterbase/core:test: (pass) vector/search - buildVectorSearchQuery > uses correct operator for euclidean +betterbase-base-template:test: <-- GET /api/users +@betterbase/core:test: (pass) vector/search - buildVectorSearchQuery > uses correct operator for inner_product [7.00ms] +@betterbase/core:test: (pass) vector/search - createVectorIndex > creates HNSW index +@betterbase/core:test: (pass) vector/search - createVectorIndex > creates IVFFlat index +@betterbase/core:test: (pass) vector/search - createVectorIndex > uses correct ops for euclidean +@betterbase/core:test: (pass) vector/search - createVectorIndex > uses correct ops for inner_product +@betterbase/core:test: (pass) vector/search - createVectorIndex > respects custom connection count +betterbase-base-template:test: --> GET /api/users 200 38ms +betterbase-base-template:test: (pass) users CRUD endpoint > GET /api/users > returns empty users array when no users exist [46.00ms] +betterbase-base-template:test: <-- GET /api/users?limit=10&offset=5 +betterbase-base-template:test: --> GET /api/users?limit=10&offset=5 200 2ms +betterbase-base-template:test: (pass) users CRUD endpoint > GET /api/users > accepts limit and offset query parameters [5.00ms] +betterbase-base-template:test: <-- GET /api/users?limit=-1 +betterbase-base-template:test: --> GET /api/users?limit=-1 400 1ms +betterbase-base-template:test: <-- GET /api/users?limit=abc +betterbase-base-template:test: --> GET /api/users?limit=abc 400 0ms +betterbase-base-template:test: (pass) users CRUD endpoint > GET /api/users > returns 400 for invalid limit [3.00ms] +betterbase-base-template:test: <-- POST /api/users +betterbase-base-template:test: (pass) users CRUD endpoint > GET /api/users > returns 400 for non-numeric limit +betterbase-base-template:test: --> POST /api/users 200 1ms +betterbase-base-template:test: (pass) users CRUD endpoint > POST /api/users > validates payload but does not persist (stub behavior) [2.00ms] +betterbase-base-template:test: <-- POST /api/users +betterbase-base-template:test: --> POST /api/users 400 1ms +betterbase-base-template:test: (pass) users CRUD endpoint > POST /api/users > returns 400 for missing email [2.00ms] +betterbase-base-template:test: <-- POST /api/users +betterbase-base-template:test: --> POST /api/users 400 0ms +betterbase-base-template:test: (pass) users CRUD endpoint > POST /api/users > returns 400 for invalid email +betterbase-base-template:test: <-- POST /api/users +betterbase-base-template:test: --> POST /api/users 400 0ms +betterbase-base-template:test: (pass) users CRUD endpoint > POST /api/users > returns 400 for malformed JSON [1.00ms] +@betterbase/cli:test: (pass) Prompt utilities > text prompt > accepts valid text prompt options [58.00ms] +betterbase-base-template:test: +betterbase-base-template:test: 9 pass +betterbase-base-template:test: 0 fail +betterbase-base-template:test: 21 expect() calls +betterbase-base-template:test: Ran 9 tests across 2 files. [2.07s] +@betterbase/cli:test: (pass) Prompt utilities > text prompt > accepts initial value option [5.00ms] +@betterbase/cli:test: (pass) Prompt utilities > confirm prompt > validates message is required +@betterbase/core:test: (pass) vector - config integration > BetterBaseConfigSchema accepts vector config [67.00ms] +@betterbase/core:test: (pass) vector - config integration > BetterBaseConfigSchema accepts vector config with apiKey [2.00ms] +@betterbase/cli:test: (pass) Prompt utilities > confirm prompt > accepts valid confirm prompt options [3.00ms] +@betterbase/cli:test: (pass) Prompt utilities > confirm prompt > accepts initial option for backward compatibility [2.00ms] +@betterbase/core:test: +@betterbase/core:test: test/vector-search.test.ts: +@betterbase/cli:test: (pass) Prompt utilities > select prompt > validates message is required [4.00ms] +@betterbase/cli:test: (pass) Prompt utilities > select prompt > validates options are required +@betterbase/core:test: (pass) Vector Search > pgvector operator mappings > should have cosine distance operator +@betterbase/core:test: (pass) Vector Search > pgvector operator mappings > should have euclidean distance operator +@betterbase/core:test: (pass) Vector Search > pgvector operator mappings > should have inner product operator +@betterbase/core:test: (pass) Vector Search > pgvector operator mappings > should have correct operator mappings for all metrics +@betterbase/core:test: (pass) Vector Search > validateEmbedding > should accept valid embedding +@betterbase/core:test: (pass) Vector Search > validateEmbedding > should reject non-array embedding +@betterbase/core:test: (pass) Vector Search > validateEmbedding > should reject empty embedding +@betterbase/core:test: (pass) Vector Search > validateEmbedding > should reject embedding with NaN values +@betterbase/core:test: (pass) Vector Search > validateEmbedding > should reject embedding with non-number values +@betterbase/core:test: (pass) Vector Search > validateEmbedding > should handle high-dimensional embeddings [2.00ms] +@betterbase/core:test: (pass) Vector Search > vectorSearch > should return search results with default limit [1.00ms] +@betterbase/core:test: (pass) Vector Search > vectorSearch > should respect custom limit +@betterbase/core:test: (pass) Vector Search > vectorSearch > should include score when requested [1.00ms] +@betterbase/core:test: (pass) Vector Search > vectorSearch > should support different distance metrics +@betterbase/core:test: (pass) Vector Search > vectorSearch > should handle threshold option +@betterbase/core:test: (pass) Vector Search > embedding generation > should generate embedding with default settings +@betterbase/core:test: (pass) Vector Search > embedding generation > should generate embedding with custom dimensions [1.00ms] +@betterbase/core:test: (pass) Vector Search > embedding generation > should generate embedding with different providers [2.00ms] +@betterbase/core:test: (pass) Vector Search > embedding generation > should use custom model when specified +@betterbase/core:test: (pass) Vector Search > semantic search use cases > should perform semantic search on documents [1.00ms] +@betterbase/core:test: (pass) Vector Search > semantic search use cases > should limit search results +@betterbase/core:test: (pass) Vector Search > semantic search use cases > should handle empty document list [1.00ms] +@betterbase/core:test: +@betterbase/core:test: test/branching.test.ts: +@betterbase/cli:test: ? Enter your name:? Enter your name: (John)? Continue? (Y/n)? Continue? (y/N)? Select one: (Use arrow keys) +@betterbase/cli:test: (pass) Prompt utilities > select prompt > validates option has value and label [14.00ms] +@betterbase/cli:test: ❯ Neon[?25l? Select provider: (Use arrow keys) +@betterbase/cli:test: ❯ Neon +@betterbase/cli:test: (pass) Prompt utilities > select prompt > accepts default option [6.00ms] +@betterbase/cli:test: Turso[?25l? Select provider: (Use arrow keys) +@betterbase/cli:test: Neon +@betterbase/cli:test: MaxListenersExceededWarning: Possible EventTarget memory leak detected. 21 keypress listeners added to [ReadStream]. MaxListeners is undefined. Use events.setMaxListeners() to increase limit +@betterbase/cli:test: emitter: ReadStream { +@betterbase/cli:test: fd: 0, +@betterbase/cli:test: [Symbol(kFs)]: [Object ...], +@betterbase/cli:test: start: undefined, +@betterbase/cli:test: end: Infinity, +@betterbase/cli:test: pos: undefined, +@betterbase/cli:test: bytesRead: 0, +@betterbase/cli:test: [Symbol(kReadStreamFastPath)]: false, +@betterbase/cli:test: _events: [Object ...], +@betterbase/cli:test: _readableState: [Object ...], +@betterbase/cli:test: _maxListeners: undefined, +@betterbase/cli:test: [Symbol(kCapture)]: false, +@betterbase/cli:test: _eventsCount: NaN, +@betterbase/cli:test: on: [Function], +@betterbase/cli:test: addListener: [Function], +@betterbase/cli:test: ref: [Function], +@betterbase/cli:test: unref: [Function], +@betterbase/cli:test: pause: [Function], +@betterbase/cli:test: resume: [Function], +@betterbase/cli:test: _read: [Function: triggerRead], +@betterbase/cli:test: [Symbol(keypress-decoder)]: [StringDecoder ...], +@betterbase/cli:test: [Symbol(escape-decoder)]: {}, +@betterbase/cli:test: autoClose: [Getter/Setter], +@betterbase/cli:test: open: [Function: open], +@betterbase/cli:test: _construct: [Function: streamConstruct], +@betterbase/cli:test: _destroy: [Function], +@betterbase/cli:test: close: [Function], +@betterbase/cli:test: pending: [Getter], +@betterbase/cli:test: pipe: [Function], +@betterbase/cli:test: destroy: [Function: destroy], +@betterbase/cli:test: _undestroy: [Function: undestroy], +@betterbase/cli:test: push: [Function], +@betterbase/cli:test: unshift: [Function], +@betterbase/cli:test: isPaused: [Function], +@betterbase/cli:test: setEncoding: [Function], +@betterbase/cli:test: read: [Function], +@betterbase/cli:test: unpipe: [Function], +@betterbase/cli:test: removeListener: [Function], +@betterbase/cli:test: off: [Function], +@betterbase/cli:test: removeAllListeners: [Function], +@betterbase/cli:test: wrap: [Function], +@betterbase/cli:test: iterator: [Function], +@betterbase/cli:test: readable: [Getter/Setter], +@betterbase/cli:test: readableDidRead: [Getter], +@betterbase/cli:test: readableAborted: [Getter], +@betterbase/cli:test: readableHighWaterMark: [Getter], +@betterbase/cli:test: readableBuffer: [Getter], +@betterbase/cli:test: readableFlowing: [Getter/Setter], +@betterbase/cli:test: readableLength: [Getter], +@betterbase/cli:test: readableObjectMode: [Getter], +@betterbase/cli:test: readableEncoding: [Getter], +@betterbase/cli:test: errored: [Getter], +@betterbase/cli:test: closed: [Getter], +@betterbase/cli:test: destroyed: [Getter/Setter], +@betterbase/cli:test: readableEnded: [Getter], +@betterbase/cli:test: drop: [Function], +@betterbase/cli:test: filter: [Function], +@betterbase/cli:test: flatMap: [Function], +@betterbase/cli:test: map: [Function], +@betterbase/cli:test: take: [Function], +@betterbase/cli:test: compose: [Function], +@betterbase/cli:test: every: [Function], +@betterbase/cli:test: forEach: [Function], +@betterbase/cli:test: reduce: [Function], +@betterbase/cli:test: toArray: [Function], +@betterbase/cli:test: some: [Function], +@betterbase/cli:test: find: [Function], +@betterbase/cli:test: [Symbol(nodejs.rejection)]: [Function], +@betterbase/cli:test: [Symbol(Symbol.asyncDispose)]: [Function], +@betterbase/cli:test: [Symbol(Symbol.asyncIterator)]: [Function], +@betterbase/cli:test: eventNames: [Function: eventNames], +@betterbase/cli:test: setMaxListeners: [Function: setMaxListeners], +@betterbase/cli:test: getMaxListeners: [Function: getMaxListeners], +@betterbase/cli:test: emit: [Function: emit], +@betterbase/cli:test: prependListener: [Function: prependListener], +@betterbase/cli:test: once: [Function: once], +@betterbase/cli:test: prependOnceListener: [Function: prependOnceListener], +@betterbase/cli:test: listeners: [Function: listeners], +@betterbase/cli:test: rawListeners: [Function: rawListeners], +@betterbase/cli:test: listenerCount: [Function: listenerCount], +@betterbase/cli:test: }, +@betterbase/cli:test: type: "keypress", +@betterbase/cli:test: count: 21, +@betterbase/cli:test: +@betterbase/cli:test: at overflowWarning (node:events:185:19) +@betterbase/cli:test: at addListener (node:events:158:22) +@betterbase/cli:test: at (internal:streams/readable:519:38) +@betterbase/cli:test: at (/workspaces/Betterbase/node_modules/@inquirer/core/dist/esm/lib/use-keypress.mjs:14:18) +@betterbase/cli:test: at (/workspaces/Betterbase/node_modules/@inquirer/core/dist/esm/lib/hook-engine.mjs:84:29) +@betterbase/cli:test: at (/workspaces/Betterbase/node_modules/@inquirer/core/dist/esm/lib/hook-engine.mjs:95:17) +@betterbase/cli:test: at forEach (1:11) +@betterbase/cli:test: at (/workspaces/Betterbase/node_modules/@inquirer/core/dist/esm/lib/hook-engine.mjs:94:31) +@betterbase/cli:test: at wrapped (/workspaces/Betterbase/node_modules/@inquirer/core/dist/esm/lib/hook-engine.mjs:50:29) +@betterbase/cli:test: at runInAsyncScope (node:async_hooks:137:23) +@betterbase/cli:test: +@betterbase/cli:test: (pass) Prompt utilities > select prompt > accepts initial option for backward compatibility [16.00ms] +@betterbase/cli:test: (pass) Prompt utilities > select prompt > validates default matches an option value +@betterbase/cli:test: +@betterbase/cli:test: test/scanner.test.ts: +@betterbase/core:test: (pass) branching/types - BranchStatus > BranchStatus enum values exist +@betterbase/core:test: (pass) branching/types - BranchStatus > BranchStatus enum can be used in comparisons +@betterbase/core:test: (pass) branching/types - BranchConfig > BranchConfig has all required properties [1.00ms] +@betterbase/core:test: (pass) branching/types - CreateBranchOptions > CreateBranchOptions has correct defaults +@betterbase/core:test: (pass) branching/types - CreateBranchOptions > CreateBranchOptions accepts all options +@betterbase/core:test: (pass) branching/types - PreviewEnvironment > PreviewEnvironment has correct structure +@betterbase/core:test: (pass) branching/types - BranchingConfig > BranchingConfig has correct defaults +@betterbase/core:test: (pass) branching/types - BranchOperationResult > BranchOperationResult success structure +@betterbase/core:test: (pass) branching/types - BranchOperationResult > BranchOperationResult failure structure +@betterbase/core:test: (pass) branching/types - BranchListResult > BranchListResult has correct structure +@betterbase/core:test: (pass) branching/database - DatabaseBranching > constructor > creates DatabaseBranching instance [1.00ms] +@betterbase/core:test: (pass) branching/database - DatabaseBranching > isBranchingSupported > returns true for postgres provider +@betterbase/core:test: (pass) branching/database - DatabaseBranching > isBranchingSupported > returns true for neon provider +@betterbase/core:test: (pass) branching/database - DatabaseBranching > isBranchingSupported > returns true for supabase provider +@betterbase/core:test: (pass) branching/database - DatabaseBranching > isBranchingSupported > returns true for managed provider +@betterbase/core:test: (pass) branching/database - DatabaseBranching > isBranchingSupported > returns false for turso provider +@betterbase/core:test: (pass) branching/database - DatabaseBranching > isBranchingSupported > returns false for planetscale provider +@betterbase/core:test: (pass) branching/database - DatabaseBranching > cloneDatabase > throws error for unsupported provider [1.00ms] +@betterbase/core:test: (pass) branching/database - DatabaseBranching > connectPreviewDatabase > returns a postgres client [4.00ms] +@betterbase/core:test: (pass) branching/database - DatabaseBranching > getMainDatabase > returns a postgres client for main database +@betterbase/core:test: (pass) branching/database - DatabaseBranching > listPreviewDatabases > returns array of preview database names [1.00ms] +@betterbase/core:test: (pass) branching/database - DatabaseBranching > previewDatabaseExists > returns promise for checking database existence +@betterbase/core:test: (pass) branching/database - DatabaseBranching > teardownPreviewDatabase > returns promise for teardown operation +@betterbase/core:test: (pass) branching/database - buildBranchConfig > builds BranchConfig with correct properties +@betterbase/core:test: (pass) branching/storage - StorageBranching > constructor > creates StorageBranching instance +@betterbase/core:test: (pass) branching/storage - StorageBranching > createPreviewBucket > creates preview bucket with correct naming [1.00ms] +@betterbase/core:test: (pass) branching/storage - StorageBranching > createPreviewBucket > returns PreviewStorage with publicUrl +@betterbase/core:test: (pass) branching/storage - StorageBranching > copyFilesToPreview > returns 0 when main bucket is empty +@betterbase/core:test: (pass) branching/storage - StorageBranching > copyFilesToPreview > copies files from main bucket to preview bucket [1.00ms] +@betterbase/core:test: (pass) branching/storage - StorageBranching > copyFilesToPreview > copies files with prefix filter +@betterbase/core:test: (pass) branching/storage - StorageBranching > teardownPreviewStorage > handles empty bucket gracefully +@betterbase/core:test: (pass) branching/storage - StorageBranching > teardownPreviewStorage > deletes files from preview bucket [1.00ms] +@betterbase/core:test: (pass) branching/storage - StorageBranching > getPublicUrl > returns public URL for bucket and key +@betterbase/core:test: (pass) branching/storage - StorageBranching > getMainStorageAdapter > returns the main storage adapter +@betterbase/core:test: (pass) branching/storage - StorageBranching > getPreviewStorageAdapter > returns storage adapter for preview bucket +@betterbase/core:test: (pass) branching/storage - StorageBranching > listPreviewBuckets > returns empty array by default +@betterbase/core:test: (pass) branching/storage - StorageBranching > previewBucketExists > returns true if bucket is accessible +@betterbase/core:test: (pass) branching - BranchManager > constructor > creates BranchManager instance [1.00ms] +@betterbase/core:test: (pass) branching - BranchManager > constructor > initializes with default config +@betterbase/core:test: (pass) branching - BranchManager > setConfig and getConfig > updates configuration +@betterbase/core:test: (pass) branching - BranchManager > setConfig and getConfig > merges partial config +@betterbase/core:test: (pass) branching - BranchManager > setMainBranch and getMainBranch > sets and gets main branch name +@betterbase/core:test: (pass) branching - BranchManager > setMainBranch and getMainBranch > defaults to main +@betterbase/core:test: (pass) branching - BranchManager > createBranch > creates a new branch successfully [1.00ms] +@betterbase/core:test: (pass) branching - BranchManager > createBranch > creates branch with custom source branch +@betterbase/core:test: (pass) branching - BranchManager > createBranch > creates branch with custom sleep timeout +@betterbase/core:test: (pass) branching - BranchManager > createBranch > creates branch with custom metadata +@betterbase/core:test: (pass) branching - BranchManager > createBranch > fails when branching is disabled +@betterbase/core:test: (pass) branching - BranchManager > createBranch > fails when max previews reached +@betterbase/core:test: (pass) branching - BranchManager > createBranch > generates preview URL [1.00ms] +@betterbase/cli:test: (pass) SchemaScanner > extracts tables, columns, relations, and indexes from drizzle schema [9.00ms] +@betterbase/cli:test: (pass) SchemaScanner > handles empty tables (zero columns) [1.00ms] +@betterbase/core:test: (pass) branching - BranchManager > getBranch > retrieves branch by ID +@betterbase/core:test: (pass) branching - BranchManager > getBranch > returns undefined for non-existent branch +@betterbase/cli:test: (pass) SchemaScanner > handles tables with no relations [3.00ms] +@betterbase/cli:test: (pass) SchemaScanner > handles circular foreign key dependencies [1.00ms] +@betterbase/cli:test: (pass) SchemaScanner > handles array columns +@betterbase/cli:test: (pass) SchemaScanner > handles enum columns [1.00ms] +@betterbase/core:test: (pass) branching - BranchManager > getBranch > updates lastAccessedAt when retrieving [14.00ms] +@betterbase/cli:test: (pass) SchemaScanner > handles large complex schema with 5 interconnected tables [4.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/migrate.test.ts: +@betterbase/core:test: (pass) branching - BranchManager > getBranchByName > retrieves branch by name [3.00ms] +@betterbase/core:test: (pass) branching - BranchManager > getBranchByName > returns undefined for non-existent name +@betterbase/core:test: (pass) branching - BranchManager > listBranches > lists all branches [2.00ms] +@betterbase/core:test: (pass) branching - BranchManager > listBranches > filters by status +@betterbase/core:test: (pass) branching - BranchManager > listBranches > applies pagination +@betterbase/core:test: (skip) branching - BranchManager > listBranches > sorts by creation date (newest first) +@betterbase/core:test: (pass) branching - BranchManager > deleteBranch > deletes a branch successfully [3.00ms] +@betterbase/core:test: (pass) branching - BranchManager > deleteBranch > returns error for non-existent branch [3.00ms] +@betterbase/core:test: (pass) branching - BranchManager > sleepBranch > puts a branch to sleep [3.00ms] +@betterbase/core:test: (pass) branching - BranchManager > sleepBranch > fails if branch is already sleeping +@betterbase/core:test: (pass) branching - BranchManager > sleepBranch > fails if branch is deleted [1.00ms] +@betterbase/core:test: (pass) branching - BranchManager > wakeBranch > wakes a sleeping branch [1.00ms] +@betterbase/core:test: (pass) branching - BranchManager > wakeBranch > fails if branch is already active +@betterbase/core:test: (pass) branching - BranchManager > wakeBranch > fails if branch is deleted [3.00ms] +@betterbase/core:test: (pass) branching - BranchManager > getPreviewEnvironment > returns full preview environment details [1.00ms] +@betterbase/core:test: (pass) branching - BranchManager > getPreviewEnvironment > returns null for non-existent branch [1.00ms] +@betterbase/core:test: (pass) branching - Edge Cases > empty branch name > creates branch with empty name [1.00ms] +@betterbase/core:test: (pass) branching - Edge Cases > special characters in branch name > handles special characters in branch name +@betterbase/core:test: (pass) branching - Edge Cases > concurrent branch creation > handles multiple concurrent branch creations [4.00ms] +@betterbase/core:test: (pass) branching - Edge Cases > config without storage > creates manager without storage config +@betterbase/core:test: (pass) branching - Edge Cases > config without database connection > creates manager without database connection [2.00ms] +@betterbase/core:test: (pass) branching - Integration > full branch lifecycle +@betterbase/core:test: (pass) branching - Integration > branch pagination edge cases [1.00ms] +@betterbase/core:test: (pass) branching - Integration > multiple branches with different statuses [10.00ms] +@betterbase/core:test: (pass) branching - Utility Functions > getAllBranches returns empty map initially +@betterbase/core:test: (pass) branching - Utility Functions > getAllBranches returns created branches +@betterbase/core:test: (pass) branching - Utility Functions > clearAllBranches removes all branches +@betterbase/core:test: +@betterbase/core:test: test/iac-edge-cases.test.ts: +@betterbase/cli:test: (pass) splitStatements > splits two statements separated by semicolons +@betterbase/cli:test: (pass) splitStatements > trims whitespace from each statement +@betterbase/cli:test: (pass) splitStatements > ignores empty statements from consecutive semicolons +@betterbase/core:test: (pass) Edge Cases: Validators > v.id() with empty string table name +@betterbase/cli:test: (pass) splitStatements > returns empty array for empty input [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Validators > v.id() with special characters in table name [1.00ms] +@betterbase/cli:test: (pass) splitStatements > returns single item for input with no semicolons +@betterbase/cli:test: (pass) splitStatements > handles strings with semicolons inside quotes +@betterbase/core:test: (pass) Edge Cases: Validators > v.optional() with nested optional +@betterbase/cli:test: (pass) splitStatements > handles double-quoted strings with semicolons +@betterbase/cli:test: (pass) splitStatements > handles backtick-quoted strings with semicolons +@betterbase/cli:test: (pass) analyzeMigration > returns empty changes for empty array +@betterbase/core:test: (pass) Edge Cases: Validators > v.array() with complex element type [2.00ms] +@betterbase/cli:test: (pass) analyzeMigration > detects CREATE TABLE as non-destructive +@betterbase/cli:test: (pass) analyzeMigration > detects ADD COLUMN as non-destructive +@betterbase/cli:test: (pass) analyzeMigration > detects DROP TABLE as destructive +@betterbase/cli:test: (pass) analyzeMigration > detects DROP COLUMN as destructive [1.00ms] +@betterbase/cli:test: (pass) analyzeMigration > handles multiple statements with mixed destructiveness +@betterbase/cli:test: (pass) analyzeMigration > case-insensitive detection of DROP TABLE +@betterbase/cli:test: (pass) analyzeMigration > handles IF NOT EXISTS for CREATE TABLE +@betterbase/cli:test: (pass) analyzeMigration > handles IF EXISTS for DROP TABLE +@betterbase/cli:test: +@betterbase/cli:test: test/migrate-from-convex.test.ts: +@betterbase/core:test: (pass) Edge Cases: Validators > v.union() with many variants [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Validators > v.object() with optional nested fields +@betterbase/core:test: (pass) Edge Cases: Validators > v.object() with deeply nested objects +@betterbase/core:test: (pass) Edge Cases: Validators > v.datetime() with various ISO formats [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Validators > v.bytes() with valid base64 +@betterbase/cli:test: ❯ Turso[?25lMigrating Convex project from /tmp/bb-convex-input-MLFp6r... +@betterbase/cli:test: Output will be in /tmp/bb-convex-output-sGmAWf +@betterbase/core:test: (pass) Edge Cases: Validators > v.literal() with various primitive types [1.00ms] +@betterbase/cli:test: ✅ Converted schema.ts +@betterbase/core:test: (pass) Edge Cases: Schema Definition > defineTable with no user fields (system fields only) +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/bb-convex-output-sGmAWf +@betterbase/cli:test: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/bb-convex-output-sGmAWf/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 0 +@betterbase/cli:test: - Warnings: 0 +@betterbase/cli:test: - Files requiring manual review: 0 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/cli:test: (pass) runMigrateFromConvex compatibility report > creates report files even when query/mutation/action directories are missing [2.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Definition > defineTable with all field types [1.00ms] +@betterbase/cli:test: Migrating Convex project from /tmp/bb-convex-input-FVB8Jd... +@betterbase/cli:test: (pass) runMigrateFromConvex compatibility report > detects compatibility blockers and warnings in converted functions [1.00ms] +@betterbase/cli:test: Output will be in /tmp/bb-convex-output-bttYLq +@betterbase/cli:test: ✅ Converted 1 actions +@betterbase/cli:test: +@betterbase/cli:test: ✅ Convex Migration Complete! +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Converted files are in: /tmp/bb-convex-output-bttYLq +@betterbase/cli:test: +@betterbase/cli:test: test/edge-cases.test.ts: +@betterbase/cli:test: Key changes made: +@betterbase/cli:test: - Convex v.* validators -> BetterBase v.* +@betterbase/cli:test: - Convex query/mutation/action -> BetterBase query/mutation/action +@betterbase/cli:test: - ctx.db.query() syntax preserved +@betterbase/cli:test: - ctx.runQuery/ctx.runMutation -> ctx.runQuery/ctx.runMutation +@betterbase/cli:test: +@betterbase/cli:test: Manual steps required: +@betterbase/cli:test: 1. Review the generated schema and adjust types if needed +@betterbase/cli:test: 2. Install dependencies: bun add @betterbase/core @betterbase/client +@betterbase/cli:test: 3. Run bb iac sync to create database tables +@betterbase/cli:test: 4. Test your functions +@betterbase/cli:test: 5. Review compatibility report: /tmp/bb-convex-output-bttYLq/betterbase/convex-migration-report.json +@betterbase/cli:test: +@betterbase/cli:test: Compatibility summary: +@betterbase/cli:test: - Blockers: 1 +@betterbase/cli:test: - Warnings: 1 +@betterbase/cli:test: - Files requiring manual review: 1 +@betterbase/cli:test: +@betterbase/cli:test: See docs/iac/migration-from-convex.md for detailed guide. +@betterbase/cli:test: +@betterbase/core:test: (pass) Edge Cases: Schema Definition > defineSchema with empty tables +@betterbase/core:test: (pass) Edge Cases: Schema Definition > defineSchema with many tables [2.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Definition > table with multiple indexes on same fields +@betterbase/core:test: (pass) Edge Cases: Schema Definition > table with index on system field [2.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Serialization > serializeSchema with empty schema +@betterbase/core:test: (pass) Edge Cases: Schema Serialization > serializeSchema with deeply nested object +@betterbase/core:test: (pass) Edge Cases: Schema Serialization > serializeSchema with array of objects [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Serialization > serializeSchema with union type [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Serialization > serializeSchema preserves index metadata [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Diff > diffSchemas with multiple table changes +@betterbase/core:test: (pass) Edge Cases: Schema Diff > diffSchemas with optional to required change [1.00ms] +@betterbase/cli:test: (pass) SchemaScanner — malformed and edge inputs > does not throw on completely empty file [3.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Diff > diffSchemas with required to optional change [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Diff > diffSchemas with index changes only [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Diff > diffSchemas with no changes returns empty +@betterbase/cli:test: (pass) SchemaScanner — malformed and edge inputs > returns empty object for empty file [2.00ms] +@betterbase/core:test: (pass) Edge Cases: Schema Diff > formatDiff with empty diff [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Function Registration > query with empty args +@betterbase/cli:test: (pass) SchemaScanner — malformed and edge inputs > returns empty object for schema with only import statements [1.00ms] +@betterbase/cli:test: (pass) SchemaScanner — malformed and edge inputs > returns empty object for schema with only comments [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Function Registration > query with complex nested args [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Function Registration > mutation returns null +@betterbase/core:test: (pass) Edge Cases: Function Registration > action with side effects only +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateDrizzleSchema with no tables +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateDrizzleSchema with all SQL types [1.00ms] +@betterbase/cli:test: (pass) SchemaScanner — malformed and edge inputs > does not throw on schema with syntax errors [1.00ms] +@betterbase/cli:test: (pass) SchemaScanner — malformed and edge inputs > handles very long column names without throwing [1.00ms] +@betterbase/cli:test: (pass) SchemaScanner — malformed and edge inputs > throws when file does not exist +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateDrizzleSchema preserves indexes in output [3.00ms] +@betterbase/cli:test: (pass) RouteScanner — malformed and edge inputs > does not throw on empty file [2.00ms] +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateMigration with DROP_INDEX [3.00ms] +@betterbase/cli:test: (pass) RouteScanner — malformed and edge inputs > scan() result is defined for empty file +@betterbase/cli:test: (pass) RouteScanner — malformed and edge inputs > does not throw on file with no route registrations [1.00ms] +@betterbase/cli:test: (pass) RouteScanner — malformed and edge inputs > does not throw on malformed TypeScript [2.00ms] +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateMigration with DROP_TABLE [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateMigration filename handles special characters [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateMigration pads sequence correctly +@betterbase/cli:test: (pass) RouteScanner — malformed and edge inputs > does not throw on deeply nested code [2.00ms] +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateApiTypes with empty functions [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Code Generation > generateApiTypes with deeply nested path +@betterbase/core:test: (pass) Edge Cases: Round-trip Serialization > serialize -> deserialize -> diff produces no changes +@betterbase/core:test: (pass) Edge Cases: Round-trip Serialization > generated code is parseable for empty schema +@betterbase/core:test: (pass) Edge Cases: Null Handling > v.null() accepts null only [1.00ms] +@betterbase/cli:test: (pass) ContextGenerator — boundary conditions > does not throw on project with no schema and no routes [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Null Handling > optional field can be null [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Type Inference > Infer works with v.string() +@betterbase/core:test: (pass) Edge Cases: Type Inference > Infer works with v.number() [1.00ms] +@betterbase/core:test: (pass) Edge Cases: Type Inference > Infer works with v.object() +@betterbase/core:test: (pass) Edge Cases: Type Inference > Infer works with v.array() +@betterbase/core:test: +@betterbase/core:test: test/rls-generator.test.ts: +@betterbase/cli:test: (pass) ContextGenerator — boundary conditions > generate() returns an object [6.00ms] +@betterbase/cli:test: (pass) ContextGenerator — boundary conditions > output is always JSON-serializable [1.00ms] +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should generate SQL for SELECT policy [1.00ms] +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should generate SQL for INSERT policy +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should generate SQL for UPDATE policy +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should generate SQL for DELETE policy +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should generate SQL for multiple operations +@betterbase/cli:test: (pass) ContextGenerator — boundary conditions > handles empty schema file without throwing [1.00ms] +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should use USING clause for SELECT +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should use WITH CHECK clause for INSERT +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should prioritize using clause over operation-specific for SELECT/DELETE/UPDATE +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should prioritize withCheck clause over operation-specific for INSERT/UPDATE +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should handle true policy (allow all) +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should handle false policy (deny all) +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should include operations when using or withCheck is defined [1.00ms] +@betterbase/core:test: (pass) RLS Generator > policyToSQL > should enable RLS first +@betterbase/core:test: (pass) RLS Generator > dropPolicySQL > should generate DROP statements for all operations +@betterbase/core:test: (pass) RLS Generator > dropPolicySQL > should disable RLS last +@betterbase/core:test: (pass) RLS Generator > dropPolicyByName > should generate DROP statement for specific operation +@betterbase/core:test: (pass) RLS Generator > dropPolicyByName > should work for all operation types [1.00ms] +@betterbase/core:test: (pass) RLS Generator > disableRLS > should generate ALTER TABLE DISABLE RLS statement +@betterbase/core:test: (pass) RLS Generator > hasPolicyConditions > should return true when select is defined +@betterbase/core:test: (pass) RLS Generator > hasPolicyConditions > should return true when insert is defined +@betterbase/core:test: (pass) RLS Generator > hasPolicyConditions > should return true when update is defined [1.00ms] +@betterbase/cli:test: (pass) ContextGenerator — boundary conditions > handles schema with real tables [3.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/generate-crud.test.ts: +@betterbase/core:test: (pass) RLS Generator > hasPolicyConditions > should return true when delete is defined +@betterbase/core:test: (pass) RLS Generator > hasPolicyConditions > should return true when using is defined +@betterbase/core:test: (pass) RLS Generator > hasPolicyConditions > should return true when withCheck is defined +@betterbase/core:test: (pass) RLS Generator > hasPolicyConditions > should return false when no conditions defined +@betterbase/core:test: (pass) RLS Generator > policiesToSQL > should generate SQL for multiple policies +@betterbase/core:test: (pass) RLS Generator > policiesToSQL > should handle empty array +@betterbase/core:test: (pass) RLS Generator > dropPoliciesSQL > should generate DROP SQL for multiple policies +@betterbase/core:test: (pass) RLS Generator > dropPoliciesSQL > should handle empty array +@betterbase/core:test: +@betterbase/core:test: test/rls.test.ts: +@betterbase/core:test: (pass) rls/types > definePolicy > creates a policy definition with select +@betterbase/core:test: (pass) rls/types > definePolicy > creates a policy definition with multiple operations +@betterbase/core:test: (pass) rls/types > definePolicy > creates a policy with using clause [1.00ms] +@betterbase/core:test: (pass) rls/types > definePolicy > creates a policy with withCheck clause +@betterbase/core:test: (pass) rls/types > isPolicyDefinition > returns true for valid policy +@betterbase/core:test: (pass) rls/types > isPolicyDefinition > returns false for null +@betterbase/core:test: (pass) rls/types > isPolicyDefinition > returns false for undefined [1.00ms] +@betterbase/core:test: (pass) rls/types > isPolicyDefinition > returns false for empty object +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/core:test: (pass) rls/types > isPolicyDefinition > returns false for object without table +@betterbase/core:test: (pass) rls/types > isPolicyDefinition > returns false for object with empty table [1.00ms] +@betterbase/core:test: (pass) rls/types > mergePolicies > merges policies for the same table +@betterbase/core:test: (pass) rls/types > mergePolicies > keeps separate policies for different tables [1.00ms] +@betterbase/core:test: (pass) rls/types > mergePolicies > prefers new values when merging +@betterbase/core:test: (pass) rls/generator > policyToSQL > generates SQL for select policy +@betterbase/core:test: (pass) rls/generator > policyToSQL > generates SQL for multiple operations +@betterbase/core:test: (pass) rls/generator > policyToSQL > generates USING clause for select/update/delete [1.00ms] +@betterbase/core:test: (pass) rls/generator > policyToSQL > generates WITH CHECK clause for insert/update +@betterbase/core:test: (pass) rls/generator > policyToSQL > handles insert with operation-specific condition +@betterbase/core:test: (pass) rls/generator > dropPolicySQL > generates DROP statements for all operations [1.00ms] +@betterbase/core:test: (pass) rls/generator > dropPolicyByName > generates DROP POLICY statement +@betterbase/core:test: (pass) rls/generator > disableRLS > generates ALTER TABLE statement +@betterbase/core:test: (pass) rls/generator > hasPolicyConditions > returns true when select is defined +@betterbase/core:test: (pass) rls/generator > hasPolicyConditions > returns true when using is defined +@betterbase/core:test: (pass) rls/generator > hasPolicyConditions > returns true when withCheck is defined +@betterbase/core:test: (pass) rls/generator > hasPolicyConditions > returns false when no conditions are defined [1.00ms] +@betterbase/core:test: (pass) rls/generator > policiesToSQL > generates SQL for multiple policies +@betterbase/core:test: (pass) rls/generator > dropPoliciesSQL > generates DROP SQL for multiple policies +@betterbase/core:test: (pass) rls/auth-bridge > generateAuthFunction > generates auth.uid() function SQL [1.00ms] +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/core:test: (pass) rls/auth-bridge > generateAuthFunctionWithSetting > generates auth.uid() with custom setting [1.00ms] +@betterbase/core:test: (pass) rls/auth-bridge > generateAuthFunctionWithSetting > throws for invalid setting name +@betterbase/cli:test: - Writing route file... +@betterbase/core:test: (pass) rls/auth-bridge > generateAuthFunctionWithSetting > allows valid setting names +@betterbase/core:test: (pass) rls/auth-bridge > dropAuthFunction > generates DROP FUNCTION statement [1.00ms] +@betterbase/core:test: (pass) rls/auth-bridge > setCurrentUserId > generates SET statement with user ID +@betterbase/core:test: (pass) rls/auth-bridge > setCurrentUserId > escapes single quotes in user ID +@betterbase/core:test: (pass) rls/auth-bridge > clearCurrentUserId > generates CLEAR statement +@betterbase/core:test: (pass) rls/auth-bridge > generateIsAuthenticatedCheck > generates auth.authenticated() function [1.00ms] +@betterbase/core:test: (pass) rls/auth-bridge > dropIsAuthenticatedCheck > generates DROP FUNCTION statement +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/core:test: (pass) rls/auth-bridge > generateAllAuthFunctions > returns array of all auth functions [1.00ms] +@betterbase/core:test: (pass) rls/auth-bridge > dropAllAuthFunctions > returns array of all DROP statements +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/core:test: (pass) rls/scanner > scanPolicies > returns empty result for empty directory [7.00ms] +@betterbase/core:test: (pass) rls/scanner > scanPolicies > scans and loads policies from policy files [2.00ms] +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/core:test: (pass) rls/scanner > listPolicyFiles > returns empty array for directory without policy files [2.00ms] +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/core:test: (pass) rls/scanner > listPolicyFiles > finds policy files in policies directory [1.00ms] +@betterbase/core:test: (pass) rls/scanner > getPolicyFileInfo > returns empty array for non-existent file +@betterbase/core:test: +@betterbase/core:test: test/rls-evaluator.test.ts: +@betterbase/cli:test: (pass) runGenerateCrudCommand > creates src/routes/posts.ts for posts table [32.00ms] +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route exports postsRoute [9.00ms] +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > true policy > should allow all when policy is 'true' +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > true policy > should allow all when policy is 'true' with null userId +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > false policy > should deny all when policy is 'false' +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > false policy > should deny all when policy is 'false' with null userId +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > auth.uid() = column > should allow when userId matches column value +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > auth.uid() = column > should deny when userId does not match column value [1.00ms] +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > auth.uid() = column > should deny when userId is null +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > auth.uid() = column > should handle string comparison +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > auth.uid() = column > should handle column value as number +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > auth.uid() = column > should handle missing column in record [1.00ms] +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > auth.role() = 'value' > should deny role check (not implemented) +@betterbase/core:test: [RLS] Unknown policy expression: unknown_expression +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > unknown policy format > should deny unknown policy format +@betterbase/core:test: [RLS] Unknown policy expression: +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > unknown policy format > should deny empty string policy +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > different operations > should evaluate for insert operation [1.00ms] +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > different operations > should evaluate for update operation +@betterbase/core:test: (pass) RLS Evaluator > evaluatePolicy > different operations > should evaluate for delete operation +@betterbase/core:test: (pass) RLS Evaluator > applyRLSSelect > should return all rows when no policies defined +@betterbase/core:test: (pass) RLS Evaluator > applyRLSSelect > should filter rows based on SELECT policy +@betterbase/core:test: (pass) RLS Evaluator > applyRLSSelect > should deny anonymous when no SELECT policy defined +@betterbase/core:test: (pass) RLS Evaluator > applyRLSSelect > should allow authenticated when no SELECT policy defined +@betterbase/core:test: (pass) RLS Evaluator > applyRLSSelect > should apply USING clause for SELECT +@betterbase/core:test: (pass) RLS Evaluator > applyRLSSelect > should allow all when SELECT policy is 'true' +@betterbase/core:test: (pass) RLS Evaluator > applyRLSSelect > should filter correctly for multiple policies on different tables +@betterbase/core:test: (pass) RLS Evaluator > applyRLSInsert > should throw when no policy and no user +@betterbase/core:test: (pass) RLS Evaluator > applyRLSInsert > should allow when authenticated and no policy +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route contains GET / handler [4.00ms] +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/core:test: (pass) RLS Evaluator > applyRLSInsert > should throw when policy denies [3.00ms] +@betterbase/core:test: (pass) RLS Evaluator > applyRLSInsert > should allow when policy allows +@betterbase/core:test: (pass) RLS Evaluator > applyRLSInsert > should evaluate auth.uid() check +@betterbase/core:test: (pass) RLS Evaluator > applyRLSUpdate > should throw when no policy and no user +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/core:test: (pass) RLS Evaluator > applyRLSUpdate > should allow when authenticated and no policy +@betterbase/core:test: (pass) RLS Evaluator > applyRLSUpdate > should throw when policy denies +@betterbase/core:test: (pass) RLS Evaluator > applyRLSUpdate > should allow when policy allows +@betterbase/core:test: (pass) RLS Evaluator > applyRLSUpdate > should evaluate using clause for update [2.00ms] +@betterbase/core:test: (pass) RLS Evaluator > applyRLSDelete > should throw when no policy and no user +@betterbase/core:test: (pass) RLS Evaluator > applyRLSDelete > should allow when authenticated and no policy +@betterbase/core:test: (pass) RLS Evaluator > applyRLSDelete > should throw when policy denies +@betterbase/core:test: (pass) RLS Evaluator > applyRLSDelete > should allow when policy allows +@betterbase/core:test: (pass) RLS Evaluator > applyRLSDelete > should evaluate auth.uid() check for delete [1.00ms] +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware.select > should filter rows based on policy +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware.insert > should allow insert when policy passes +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware.insert > should allow insert when policy is true +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware.update > should allow update when user owns record +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware.update > should throw when user does not own record +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware.delete > should allow delete when user owns record +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware.delete > should throw when user does not own record +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware with null user > should deny select when user is null +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware with null user > should throw on insert when user is null +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware with null user > should throw on update when user is null +@betterbase/core:test: (pass) RLS Evaluator > createRLSMiddleware > middleware with null user > should throw on delete when user is null +@betterbase/core:test: +@betterbase/core:test: test/graphql-resolvers.test.ts: +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route contains GET /:id handler [8.00ms] +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route contains POST handler [4.00ms] +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route contains PATCH handler [4.00ms] +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route contains DELETE handler [3.00ms] +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route imports Zod and uses zValidator [19.00ms] +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route includes pagination schema [37.00ms] +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: (pass) runGenerateCrudCommand > generated route broadcasts realtime events [31.00ms] +@betterbase/cli:test: ◆ Generating CRUD for posts... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "posts" +@betterbase/cli:test: ───────────────────────────── +@betterbase/cli:test: ├─ src/routes/posts.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table posts +@betterbase/cli:test: - Writing route file... +@betterbase/cli:test: ✓ Created src/routes/posts.ts +@betterbase/cli:test: - Updating router index... +@betterbase/cli:test: +@betterbase/cli:test: ✓ Router updated +@betterbase/cli:test: +@betterbase/cli:test: Generated endpoints +@betterbase/cli:test: ───────────────────── +@betterbase/cli:test: GET /api/posts List all (paginated) +@betterbase/cli:test: GET /api/posts/:id Get single +@betterbase/cli:test: POST /api/posts Create +@betterbase/cli:test: PATCH /api/posts/:id Update +@betterbase/cli:test: DELETE /api/posts/:id Delete +@betterbase/cli:test: ◆ Regenerating GraphQL schema... +@betterbase/cli:test: ◆ Generating GraphQL schema... +@betterbase/cli:test: ✓ GraphQL SDL written to src/lib/graphql/schema.graphql +@betterbase/cli:test: ✓ GraphQL server setup written to src/routes/graphql.ts +@betterbase/cli:test: ✓ GraphQL API generated at /api/graphql +@betterbase/cli:test: ◆ Run "bb graphql playground" to open the GraphQL Playground +@betterbase/cli:test: (pass) runGenerateCrudCommand > updates src/routes/index.ts to register the new route [23.00ms] +@betterbase/cli:test: ◆ Generating CRUD for nonexistent_table_xyz... +@betterbase/cli:test: +@betterbase/cli:test: Generating CRUD for "nonexistent_table_xyz" +@betterbase/cli:test: ───────────────────────────────────────────── +@betterbase/cli:test: ├─ src/routes/nonexistent_table_xyz.ts +@betterbase/cli:test: └─ Updated src/routes/index.ts +@betterbase/cli:test: +@betterbase/cli:test: - Scanning schema... +@betterbase/cli:test: ✓ Found table nonexistent_table_xyz +@betterbase/cli:test: (pass) runGenerateCrudCommand > throws for a table that does not exist in the schema [36.00ms] +@betterbase/cli:test: (pass) runGenerateCrudCommand > throws when schema file does not exist [17.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/auth-command.test.ts: +@betterbase/cli:test: ◆ 🔐 Setting up BetterAuth... +@betterbase/cli:test: ◆ 📦 Installing better-auth... +@betterbase/cli:test: bun add v1.3.13 (bf2e2cec) +@betterbase/cli:test: Resolving dependencies +@betterbase/core:test: (pass) GraphQL Resolvers > generateResolvers > should generate resolvers for single table [4.00ms] +@betterbase/core:test: (pass) GraphQL Resolvers > generateResolvers > should generate resolvers for multiple tables [1.00ms] +@betterbase/core:test: (pass) GraphQL Resolvers > generateResolvers > should generate subscriptions when enabled +@betterbase/core:test: (pass) GraphQL Resolvers > generateResolvers > should accept empty config +@betterbase/core:test: (pass) GraphQL Resolvers > createGraphQLContext > should create context function +@betterbase/core:test: (pass) GraphQL Resolvers > requireAuth > should wrap a resolver with auth check +@betterbase/core:test: (pass) GraphQL Resolvers > requireAuth > wrapped resolver should throw when user missing +@betterbase/core:test: (pass) GraphQL Resolvers > requireAuth > wrapped resolver should call original when user present +@betterbase/core:test: (pass) GraphQL Resolvers > resolver hooks configuration > should accept beforeCreate hook +@betterbase/core:test: (pass) GraphQL Resolvers > resolver hooks configuration > should accept afterCreate hook [1.00ms] +@betterbase/core:test: (pass) GraphQL Resolvers > resolver hooks configuration > should accept onError handler +@betterbase/core:test: (pass) GraphQL Resolvers > resolver types > should have correct Resolvers structure +@betterbase/core:test: (pass) GraphQL Resolvers > resolver types > GraphQLResolver type should accept function +@betterbase/core:test: (pass) GraphQL Resolvers > resolver types > GraphQLContext should accept db and user +@betterbase/core:test: +@betterbase/core:test: test/realtime-channel-manager.test.ts: +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should create channel manager +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should subscribe to channels [1.00ms] +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should unsubscribe from channels +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should broadcast to channels +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should handle presence +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should handle transient messages +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should handle state synchronization +@betterbase/core:test: (pass) Realtime Channel Manager > ChannelManager > should clean up disconnected clients +@betterbase/core:test: (pass) Realtime Channel Manager > createChannelManager > should create channel manager instance +@betterbase/core:test: (pass) Realtime Channel Manager > createChannelManager > should configure options +@betterbase/core:test: (pass) Realtime Channel Manager > createChannelManager > should setup event handlers +@betterbase/core:test: (pass) Channel Manager Stubs > should have placeholder for subscription +@betterbase/core:test: (pass) Channel Manager Stubs > should have placeholder for broadcast +@betterbase/core:test: (pass) Channel Manager Stubs > should have placeholder for presence +@betterbase/core:test: (pass) Channel Manager Stubs > should have placeholder for cleanup +@betterbase/core:test: +@betterbase/core:test: test/rls-scanner.test.ts: +@betterbase/core:test: (pass) RLS Scanner > scanPolicies > should return empty result when no policy directory exists +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001836/src/db/policies/users.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > scanPolicies > should scan src/db/policies directory [3.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001838/db/policies/posts.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > scanPolicies > should scan db/policies directory [1.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001839/policies/comments.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > scanPolicies > should scan policies directory [1.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001840/src/db/policies/posts.policy.ts, using regex fallback +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001840/src/db/policies/comments.policy.ts, using regex fallback +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001840/src/db/policies/users.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > scanPolicies > should load multiple policy files [2.00ms] +@betterbase/core:test: (pass) RLS Scanner > scanPolicies > should handle errors when policy file is invalid [1.00ms] +@betterbase/core:test: (pass) RLS Scanner > scanPolicies > should return empty when policy directory is empty [1.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001844/src/db/policies/users.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > scanPoliciesStrict > should return policies when scan succeeds [1.00ms] +@betterbase/core:test: (pass) RLS Scanner > scanPoliciesStrict > should throw when scan has errors [2.00ms] +@betterbase/core:test: (pass) RLS Scanner > listPolicyFiles > should return empty array when no policy directory exists [1.00ms] +@betterbase/core:test: (pass) RLS Scanner > listPolicyFiles > should return list of policy file paths [1.00ms] +@betterbase/core:test: (pass) RLS Scanner > listPolicyFiles > should return empty when policy directory is empty +@betterbase/core:test: (pass) RLS Scanner > listPolicyFiles > should not include non-policy files [1.00ms] +@betterbase/core:test: (pass) RLS Scanner > getPolicyFileInfo > should return policy file info [1.00ms] +@betterbase/core:test: (pass) RLS Scanner > getPolicyFileInfo > should return empty array when no policies [1.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001852/src/db/policies/users.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > policy file parsing > should parse policy with select condition [4.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001856/src/db/policies/posts.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > policy file parsing > should parse policy with multiple conditions [2.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001858/src/db/policies/documents.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > policy file parsing > should parse policy with using clause [2.00ms] +@betterbase/core:test: Dynamic import failed for /tmp/rls-scanner-test-1777681001860/src/db/policies/comments.policy.ts, using regex fallback +@betterbase/core:test: (pass) RLS Scanner > policy file parsing > should parse policy with withCheck clause [1.00ms] +@betterbase/core:test: (pass) RLS Scanner > PolicyScanError > should create error with message +@betterbase/core:test: (pass) RLS Scanner > PolicyScanError > should create error with cause [1.00ms] +@betterbase/core:test: +@betterbase/core:test: test/image-transformer.test.ts: +@betterbase/core:test: (pass) ImageTransformer > generateCacheKey > should generate consistent cache key for same options +@betterbase/core:test: (pass) ImageTransformer > generateCacheKey > should generate different cache key for different options [1.00ms] +@betterbase/core:test: (pass) ImageTransformer > generateCacheKey > should generate different cache key for different paths +@betterbase/core:test: (pass) ImageTransformer > generateCacheKey > should handle empty options +@betterbase/core:test: (pass) ImageTransformer > buildCachePath > should build cache path for simple filename [1.00ms] +@betterbase/core:test: (pass) ImageTransformer > buildCachePath > should build cache path for nested directory +@betterbase/core:test: (pass) ImageTransformer > buildCachePath > should handle filename without extension +@betterbase/core:test: (pass) ImageTransformer > buildCachePath > should use provided format in filename +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should parse valid width and height +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should parse valid format +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should parse valid quality [1.00ms] +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should parse valid fit +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid width (too small) +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid width (too large) +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid width (negative) +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid height (too small) +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid height (too large) +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid format +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid quality (too low) [1.00ms] +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid quality (too high) +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for invalid fit +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for empty query params +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should return null for non-numeric width [1.00ms] +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should parse multiple valid options +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should accept jpg as format +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should handle case-insensitive format +@betterbase/core:test: (pass) ImageTransformer > parseTransformOptions > should handle case-insensitive fit +@betterbase/core:test: (pass) ImageTransformer > isImage > should return true for JPEG +@betterbase/core:test: (pass) ImageTransformer > isImage > should return true for PNG +@betterbase/core:test: (pass) ImageTransformer > isImage > should return true for WebP +@betterbase/core:test: (pass) ImageTransformer > isImage > should return true for GIF +@betterbase/core:test: (pass) ImageTransformer > isImage > should return true for TIFF +@betterbase/core:test: (pass) ImageTransformer > isImage > should return true for AVIF +@betterbase/core:test: (pass) ImageTransformer > isImage > should return true for HEIF +@betterbase/core:test: (pass) ImageTransformer > isImage > should return false for PDF +@betterbase/core:test: (pass) ImageTransformer > isImage > should return false for SVG +@betterbase/core:test: (pass) ImageTransformer > isImage > should return false for unknown type +@betterbase/core:test: (pass) ImageTransformer > isImage > should return false for empty string +@betterbase/core:test: (pass) ImageTransformer > getContentType > should return image/webp for webp format +@betterbase/core:test: (pass) ImageTransformer > getContentType > should return image/jpeg for jpeg format +@betterbase/core:test: (pass) ImageTransformer > getContentType > should return image/jpeg for jpg format +@betterbase/core:test: (pass) ImageTransformer > getContentType > should return image/png for png format +@betterbase/core:test: (pass) ImageTransformer > getContentType > should return image/avif for avif format +@betterbase/core:test: (pass) ImageTransformer > getContentType > should return image/webp for unknown format +@betterbase/core:test: (pass) ImageTransformer > Edge cases > should handle path with no directory +@betterbase/core:test: (pass) ImageTransformer > Edge cases > should handle path with multiple dots +@betterbase/core:test: (pass) ImageTransformer > Edge cases > should handle parseTransformOptions with undefined values +@betterbase/core:test: (pass) ImageTransformer > Edge cases > should handle cache key with hash containing special characters +@betterbase/core:test: +@betterbase/core:test: test/config.test.ts: +@betterbase/core:test: (pass) config/schema > ProviderTypeSchema > accepts valid provider types [2.00ms] +@betterbase/core:test: (pass) config/schema > ProviderTypeSchema > rejects invalid provider types +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > validates a complete valid config [1.00ms] +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > validates config with optional storage [1.00ms] +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > validates config with webhooks +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > rejects config without project name +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > rejects config with invalid mode [1.00ms] +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > rejects config without connectionString for non-managed providers +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > validates turso provider with url and authToken +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > rejects turso provider without url +@betterbase/core:test: (pass) config/schema > BetterBaseConfigSchema > validates managed provider without connectionString +@betterbase/core:test: (pass) config/schema > defineConfig > returns validated config +@betterbase/core:test: (pass) config/schema > validateConfig > returns true for valid config [1.00ms] +@betterbase/core:test: (pass) config/schema > validateConfig > returns false for invalid config +@betterbase/core:test: (pass) config/schema > parseConfig > returns success result for valid config +@betterbase/core:test: (pass) config/schema > parseConfig > returns error result for invalid config +@betterbase/core:test: (pass) config/schema > assertConfig > does not throw for valid config +@betterbase/core:test: (pass) config/schema > assertConfig > throws for invalid config [1.00ms] +@betterbase/core:test: +@betterbase/core:test: test/storage-types.test.ts: +@betterbase/core:test: (pass) Storage Types > StorageProvider > should allow 's3' as valid provider +@betterbase/core:test: (pass) Storage Types > StorageProvider > should allow 'r2' as valid provider +@betterbase/core:test: (pass) Storage Types > StorageProvider > should allow 'backblaze' as valid provider +@betterbase/core:test: (pass) Storage Types > StorageProvider > should allow 'minio' as valid provider +@betterbase/core:test: (pass) Storage Types > StorageProvider > should allow 'managed' as valid provider +@betterbase/core:test: (pass) Storage Types > UploadOptions > should allow optional contentType +@betterbase/core:test: (pass) Storage Types > UploadOptions > should allow optional metadata +@betterbase/core:test: (pass) Storage Types > UploadOptions > should allow optional isPublic flag +@betterbase/core:test: (pass) Storage Types > UploadOptions > should allow empty options +@betterbase/core:test: (pass) Storage Types > SignedUrlOptions > should allow optional expiresIn +@betterbase/core:test: (pass) Storage Types > SignedUrlOptions > should allow empty options [1.00ms] +@betterbase/core:test: (pass) Storage Types > UploadResult > should have required key and size properties +@betterbase/core:test: (pass) Storage Types > UploadResult > should allow optional contentType and etag +@betterbase/core:test: (pass) Storage Types > StorageObject > should have required properties +@betterbase/core:test: (pass) Storage Types > StorageObject > should allow optional contentType +@betterbase/core:test: (pass) Storage Types > AllowedMimeTypes > should allow only allow list +@betterbase/core:test: (pass) Storage Types > AllowedMimeTypes > should allow deny list +@betterbase/core:test: (pass) Storage Types > AllowedMimeTypes > should allow allowListOnly flag +@betterbase/core:test: (pass) Storage Types > BucketConfig > should allow maxFileSize +@betterbase/core:test: (pass) Storage Types > BucketConfig > should allow allowedMimeTypes +@betterbase/core:test: (pass) Storage Types > BucketConfig > should allow allowedExtensions +@betterbase/core:test: (pass) Storage Types > BucketConfig > should allow empty config +@betterbase/core:test: (pass) Storage Types > defineStoragePolicy > should create storage policy with bucket, operation, and expression +@betterbase/core:test: (pass) Storage Types > defineStoragePolicy > should create policy with wildcard operation +@betterbase/core:test: (pass) Storage Types > defineStoragePolicy > should create policy with different operations +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate S3Config +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate R2Config +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate R2Config with custom endpoint +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate BackblazeConfig +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate BackblazeConfig with custom endpoint +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate MinioConfig +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate MinioConfig with full options +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate ManagedConfig [1.00ms] +@betterbase/core:test: (pass) Storage Types > StorageConfig types > should validate StorageConfig union type +@betterbase/core:test: +@betterbase/core:test: test/graphql-schema-generator.test.ts: +@betterbase/core:test: (pass) GraphQL Schema Generator > generateGraphQLSchema > should generate a valid GraphQL schema +@betterbase/core:test: (pass) GraphQL Schema Generator > generateGraphQLSchema > should generate Query type [1.00ms] +@betterbase/core:test: (pass) GraphQL Schema Generator > generateGraphQLSchema > should generate Mutation type [1.00ms] +@betterbase/core:test: (pass) GraphQL Schema Generator > generateGraphQLSchema > should generate Subscription type by default +@betterbase/core:test: (pass) GraphQL Schema Generator > generateGraphQLSchema > should handle multiple tables [3.00ms] +@betterbase/core:test: (pass) GraphQL Schema Generator > generateGraphQLSchema > should handle empty tables object +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQL scalar types > should have GraphQLJSON scalar +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQL scalar types > should have GraphQLDateTime scalar +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQL scalar types > should serialize Date to ISO string +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQL scalar types > should serialize string to string +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQL scalar types > should parse string to Date +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQL scalar types > should serialize JSON value +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQL scalar types > should parse JSON value +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQLGenerationConfig > should accept empty config object [1.00ms] +@betterbase/core:test: (pass) GraphQL Schema Generator > GraphQLGenerationConfig > should accept custom typePrefix +@betterbase/core:test: (pass) GraphQL Schema Generator > schema structure > should have proper query fields +@betterbase/core:test: (pass) GraphQL Schema Generator > schema structure > should have mutation fields when enabled [1.00ms] +@betterbase/core:test: (pass) GraphQL Schema Generator > schema structure > should have subscription fields when enabled +@betterbase/core:test: +@betterbase/core:test: test/providers.test.ts: +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > validates a valid Neon provider config [1.00ms] +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > validates a valid Turso provider config +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > validates a valid PlanetScale provider config +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > validates a valid Supabase provider config +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > validates a valid Postgres provider config [1.00ms] +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > validates a managed provider config (no required fields) +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > rejects invalid provider type +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > rejects Neon config without connectionString +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > rejects Turso config without url +@betterbase/core:test: (pass) providers/types > ProviderConfigSchema > rejects Turso config without authToken +@betterbase/core:test: (pass) providers/types > NeonProviderConfigSchema > validates valid Neon config [1.00ms] +@betterbase/core:test: (pass) providers/types > NeonProviderConfigSchema > rejects wrong type +@betterbase/core:test: (pass) providers/types > TursoProviderConfigSchema > validates valid Turso config +@betterbase/core:test: (pass) providers/types > PlanetScaleProviderConfigSchema > validates valid PlanetScale config +@betterbase/core:test: (pass) providers/types > SupabaseProviderConfigSchema > validates valid Supabase config +@betterbase/core:test: (pass) providers/types > PostgresProviderConfigSchema > validates valid Postgres config +@betterbase/core:test: (pass) providers/types > ManagedProviderConfigSchema > validates managed config with just type +@betterbase/core:test: (pass) providers/types > isValidProviderConfig > returns true for valid config +@betterbase/core:test: (pass) providers/types > isValidProviderConfig > returns false for invalid config [1.00ms] +@betterbase/core:test: (pass) providers/types > parseProviderConfig > parses valid config +@betterbase/core:test: (pass) providers/types > parseProviderConfig > throws on invalid config +@betterbase/core:test: (pass) providers/types > safeParseProviderConfig > returns success for valid config +@betterbase/core:test: (pass) providers/types > safeParseProviderConfig > returns error for invalid config +@betterbase/core:test: (pass) providers/index > getSupportedProviders > returns all supported providers except managed [1.00ms] +@betterbase/core:test: (pass) providers/index > providerSupportsRLS > returns true for PostgreSQL-based providers +@betterbase/core:test: (pass) providers/index > providerSupportsRLS > returns false for SQLite and MySQL providers +@betterbase/core:test: (pass) providers/index > providerSupportsRLS > returns true for managed provider +@betterbase/core:test: (pass) providers/index > getProviderDialect > returns postgres for PostgreSQL-based providers +@betterbase/core:test: (pass) providers/index > getProviderDialect > returns mysql for PlanetScale +@betterbase/core:test: (pass) providers/index > getProviderDialect > returns sqlite for Turso +@betterbase/core:test: (pass) providers/index > getProviderDialect > throws for managed provider +@betterbase/core:test: (pass) providers/index > resolveProvider > resolves Neon provider config +@betterbase/core:test: (pass) providers/index > resolveProvider > resolves Postgres provider config +@betterbase/core:test: (pass) providers/index > resolveProvider > resolves Supabase provider config +@betterbase/core:test: (pass) providers/index > resolveProvider > resolves Turso provider config [1.00ms] +@betterbase/core:test: (pass) providers/index > resolveProvider > resolves PlanetScale provider config +@betterbase/core:test: (pass) providers/index > resolveProvider > throws for managed provider +@betterbase/core:test: (pass) providers/index > resolveProviderByType > resolves Neon by type string +@betterbase/core:test: (pass) providers/index > resolveProviderByType > resolves Postgres by type string +@betterbase/core:test: (pass) providers/index > resolveProviderByType > resolves Supabase by type string +@betterbase/core:test: (pass) providers/index > resolveProviderByType > resolves Turso by type string +@betterbase/core:test: (pass) providers/index > resolveProviderByType > resolves PlanetScale by type string +@betterbase/core:test: (pass) providers/index > resolveProviderByType > throws for managed provider +@betterbase/core:test: (pass) providers/index > ManagedProviderNotSupportedError > has correct message +@betterbase/core:test: (pass) NeonProviderAdapter > constructor > creates adapter with correct type and dialect +@betterbase/core:test: (pass) NeonProviderAdapter > connect > validates config type [1.00ms] +@betterbase/core:test: (pass) NeonProviderAdapter > connect > creates connection on valid config +@betterbase/core:test: (pass) NeonProviderAdapter > supportsRLS > returns true +@betterbase/core:test: (pass) NeonProviderAdapter > supportsGraphQL > returns true +@betterbase/core:test: (pass) NeonProviderAdapter > getMigrationsDriver > throws if not connected first +@betterbase/core:test: (pass) NeonProviderAdapter > getMigrationsDriver > returns driver after connection +@betterbase/core:test: (pass) PostgresProviderAdapter > constructor > creates adapter with correct type and dialect [1.00ms] +@betterbase/core:test: (pass) PostgresProviderAdapter > connect > validates config type +@betterbase/core:test: (pass) PostgresProviderAdapter > supportsRLS > returns true +@betterbase/core:test: (pass) SupabaseProviderAdapter > constructor > creates adapter with correct type and dialect +@betterbase/core:test: (pass) SupabaseProviderAdapter > connect > validates config type +@betterbase/core:test: (pass) SupabaseProviderAdapter > supportsRLS > returns true +@betterbase/core:test: (pass) TursoProviderAdapter > constructor > creates adapter with correct type and dialect +@betterbase/core:test: (pass) TursoProviderAdapter > connect > validates config type +@betterbase/core:test: (pass) TursoProviderAdapter > connect > validates url is provided +@betterbase/core:test: (pass) TursoProviderAdapter > connect > validates authToken is provided [1.00ms] +@betterbase/core:test: (pass) TursoProviderAdapter > supportsRLS > returns false for SQLite +@betterbase/core:test: (pass) TursoProviderAdapter > supportsGraphQL > returns false for SQLite +@betterbase/core:test: (pass) PlanetScaleProviderAdapter > constructor > creates adapter with correct type and dialect +@betterbase/core:test: (pass) PlanetScaleProviderAdapter > connect > validates config type +@betterbase/core:test: (pass) PlanetScaleProviderAdapter > supportsRLS > returns false for MySQL +@betterbase/core:test: +@betterbase/core:test: test/chain-code-maps.test.ts: +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map varchar constructor to varchar type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map text constructor to text type [1.00ms] +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map integer constructor to integer type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map boolean constructor to boolean type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map timestamp constructor to timestamp type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map uuid constructor to uuid type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map json constructor to json type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map jsonb constructor to jsonb type (falls to json) +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map real constructor to real type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map double constructor to double type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should map numeric constructor to numeric type +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should return text as default for unknown constructor +@betterbase/core:test: (pass) Chain Code Maps - columnMap > getColumnTypeName > should handle case-insensitive constructor names +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > integer types > should map integer to Int [1.00ms] +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > integer types > should map serial to Int (falls through to text, then to String) +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > integer types > should map smallint to Int (falls through) +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > integer types > should map bigint to Int (falls through) +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > string types > should map varchar to String +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > string types > should map text to String +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > string types > should map char to String +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > boolean types > should map boolean to Boolean +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > boolean types > should map bool to Boolean (falls through) +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > uuid types > should map uuid to ID +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > timestamp/date types > should map timestamp to DateTime +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > timestamp/date types > should map date to DateTime (falls through) +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > json types > should map json to JSON +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > json types > should map jsonb to JSON [1.00ms] +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > numeric types > should map real to String +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > numeric types > should map double to String +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > numeric types > should map numeric to String +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > numeric types > should map decimal to String +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > mode-based type mapping > should map timestamp mode to DateTime +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > mode-based type mapping > should map json mode to JSON +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > mode-based type mapping > should map jsonb mode to JSON +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > mode-based type mapping > should map boolean mode to Boolean +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > default types > should default to String for unknown types +@betterbase/core:test: (pass) Chain Code Maps - getGraphQLType > default types > should default to String when constructor name is empty +@betterbase/core:test: (pass) Chain Code Maps - Integration > should correctly map a complete user table schema +@betterbase/core:test: (pass) Chain Code Maps - Integration > should correctly map a complete post table schema [1.00ms] +@betterbase/core:test: (pass) Chain Code Maps - Integration > should handle all PostgreSQL column types +@betterbase/core:test: (pass) Chain Code Maps - Integration > should handle all SQLite column types +@betterbase/core:test: (pass) Chain Code Maps - Integration > should handle all MySQL column types +@betterbase/core:test: +@betterbase/core:test: test/middleware-functions.test.ts: +@betterbase/core:test: (pass) Middleware Functions > requestLogger > should be a function +@betterbase/core:test: (pass) Middleware Functions > requestLogger > should log incoming requests +@betterbase/core:test: (pass) Middleware Functions > requestLogger > should log response status +@betterbase/core:test: (pass) Middleware Functions > requestLogger > should log request duration +@betterbase/core:test: (pass) Middleware Functions > requestLogger > should include request metadata +@betterbase/core:test: (pass) Middleware Functions > requestLogger > should handle errors gracefully +@betterbase/core:test: (pass) Request Logger Stubs > should have placeholder for logging +@betterbase/core:test: (pass) Request Logger Stubs > should have placeholder for duration +@betterbase/core:test: (pass) Request Logger Stubs > should have placeholder for metadata +@betterbase/core:test: +@betterbase/core:test: test/functions-runtime.test.ts: +@betterbase/core:test: (pass) Functions Runtime > LocalFunctionsRuntime > should initialize functions runtime +@betterbase/core:test: (pass) Functions Runtime > LocalFunctionsRuntime > should load function definitions +@betterbase/core:test: (pass) Functions Runtime > LocalFunctionsRuntime > should execute function code +@betterbase/core:test: (pass) Functions Runtime > LocalFunctionsRuntime > should handle function errors +@betterbase/core:test: (pass) Functions Runtime > LocalFunctionsRuntime > should manage function lifecycle +@betterbase/core:test: (pass) Functions Runtime > LocalFunctionsRuntime > should handle timeouts +@betterbase/core:test: (pass) Functions Runtime > LocalFunctionsRuntime > should handle memory limits +@betterbase/core:test: (pass) Functions Runtime > createFunctionsMiddleware > should create middleware for functions +@betterbase/core:test: (pass) Functions Runtime > createFunctionsMiddleware > should route requests to functions +@betterbase/core:test: (pass) Functions Runtime > createFunctionsMiddleware > should handle function responses +@betterbase/core:test: (pass) Functions Runtime > initializeFunctionsRuntime > should initialize the runtime +@betterbase/core:test: (pass) Functions Runtime > initializeFunctionsRuntime > should load all functions +@betterbase/core:test: (pass) Functions Runtime > initializeFunctionsRuntime > should setup execution environment +@betterbase/core:test: (pass) Functions Runtime Stubs > should have placeholder for initialization +@betterbase/core:test: (pass) Functions Runtime Stubs > should have placeholder for execution +@betterbase/core:test: (pass) Functions Runtime Stubs > should have placeholder for lifecycle +@betterbase/core:test: +@betterbase/core:test: test/storage-policy-engine.test.ts: +@betterbase/core:test: (pass) Storage Policy Engine > defineStoragePolicy > should create policy with bucket, operation, and expression [1.00ms] +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - true expression > should allow upload when policy is 'true' with authenticated user +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - true expression > should allow upload when policy is 'true' with anonymous user +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - true expression > should allow download when policy is 'true' +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - true expression > should allow different bucket operations +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - false expression > should deny upload when policy is 'false' +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - false expression > should deny download when policy is 'false' +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - false expression > should deny with anonymous user when policy is 'false' +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - path.startsWith expression > should allow when path starts with prefix +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - path.startsWith expression > should allow for nested paths starting with prefix +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - path.startsWith expression > should deny when path does not start with prefix +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - path.startsWith expression > should work for download operations +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - path.startsWith expression > should deny download for non-prefix paths +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - auth.uid() = path.split() expression > should allow when userId matches first path segment +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - auth.uid() = path.split() expression > should deny when userId does not match first path segment +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - auth.uid() = path.split() expression > should deny when userId is null (anonymous) +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - auth.uid() = path.split() expression > should work with longer paths [1.00ms] +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - auth.uid() = path.split with delimiter > should allow when userId matches second path segment +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - auth.uid() = path.split with delimiter > should deny when userId does not match second segment +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - auth.uid() = path.split with delimiter > should deny when userId is null +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - wildcard operation > should allow upload with wildcard policy +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - wildcard operation > should allow download with wildcard policy +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - wildcard operation > should allow list with wildcard policy +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - wildcard operation > should allow delete with wildcard policy +@betterbase/core:test: [Storage Policy] No policy found for unknown-bucket/upload, denying by default +@betterbase/core:test: [Storage Policy] No policy found for avatars/delete, denying by default +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - wildcard operation > should allow with anonymous user +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - no matching policies > should deny when no policy matches the bucket +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - no matching policies > should deny when no policy matches the operation +@betterbase/core:test: [Storage Policy] No policy found for files/list, denying by default +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - no matching policies > should deny when bucket and operation don't match +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - multiple policies > should allow if any policy matches (public path) +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - multiple policies > should allow if any policy matches (user path) +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - multiple policies > should deny if no policy matches +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - list operation > should allow list operation with 'true' policy +@betterbase/core:test: [Storage Policy] No policy found for files/list, denying by default +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - list operation > should allow list with path prefix +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - list operation > should deny list without matching policy +@betterbase/core:test: [Storage Policy] No policy found for files/delete, denying by default +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - delete operation > should allow delete operation with 'true' policy +@betterbase/core:test: (pass) Storage Policy Engine > checkStorageAccess - delete operation > should deny delete without matching policy +@betterbase/core:test: (pass) Storage Policy Engine > getPolicyDenialMessage > should return message for upload operation +@betterbase/core:test: (pass) Storage Policy Engine > getPolicyDenialMessage > should return message for download operation +@betterbase/core:test: (pass) Storage Policy Engine > getPolicyDenialMessage > should return message for list operation +@betterbase/core:test: (pass) Storage Policy Engine > getPolicyDenialMessage > should return message for delete operation +@betterbase/core:test: (pass) Storage Policy Engine > Edge cases > should handle empty path +@betterbase/core:test: (pass) Storage Policy Engine > Edge cases > should handle paths with special characters [1.00ms] +@betterbase/core:test: (pass) Storage Policy Engine > Edge cases > should handle very long paths +@betterbase/core:test: (pass) Storage Policy Engine > Edge cases > should handle bucket names with special characters +@betterbase/core:test: +@betterbase/core:test: test/auto-rest-functions.test.ts: +@betterbase/core:test: (pass) Auto-REST Functions > QUERY_OPERATORS > should define equals operator +@betterbase/core:test: (pass) Auto-REST Functions > QUERY_OPERATORS > should define not equals operator +@betterbase/core:test: (pass) Auto-REST Functions > QUERY_OPERATORS > should define greater than operator +@betterbase/core:test: (pass) Auto-REST Functions > QUERY_OPERATORS > should define less than operator +@betterbase/core:test: (pass) Auto-REST Functions > QUERY_OPERATORS > should define like operator +@betterbase/core:test: (pass) Auto-REST Functions > QUERY_OPERATORS > should define in operator +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should mount auto-rest routes +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should register CRUD endpoints +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should handle table definitions +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should apply RLS policies +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should handle query parameters +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should handle pagination +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should handle sorting +@betterbase/core:test: (pass) Auto-REST Functions > mountAutoRest > should handle filtering +@betterbase/core:test: (pass) Auto-REST Stubs > should have placeholder for operators +@betterbase/core:test: (pass) Auto-REST Stubs > should have placeholder for CRUD +@betterbase/core:test: (pass) Auto-REST Stubs > should have placeholder for pagination +@betterbase/core:test: +@betterbase/core:test: test/iac.test.ts: +@betterbase/core:test: (pass) IAC Validators (v.*) > v.string() returns ZodString +@betterbase/core:test: (pass) IAC Validators (v.*) > v.number() returns ZodNumber [1.00ms] +@betterbase/core:test: (pass) IAC Validators (v.*) > v.boolean() returns ZodBoolean +@betterbase/core:test: (pass) IAC Validators (v.*) > v.null() returns ZodNull +@betterbase/core:test: (pass) IAC Validators (v.*) > v.int64() returns ZodBigInt +@betterbase/core:test: (pass) IAC Validators (v.*) > v.any() returns ZodAny [1.00ms] +@betterbase/core:test: (pass) IAC Validators (v.*) > v.optional() wraps a validator +@betterbase/core:test: (pass) IAC Validators (v.*) > v.array() creates array schema +@betterbase/core:test: (pass) IAC Validators (v.*) > v.object() creates object schema +@betterbase/core:test: (pass) IAC Validators (v.*) > v.union() creates union schema [1.00ms] +@betterbase/core:test: (pass) IAC Validators (v.*) > v.literal() creates literal schema +@betterbase/core:test: (pass) IAC Validators (v.*) > v.id() creates branded ID type +@betterbase/core:test: (pass) IAC Validators (v.*) > v.datetime() creates datetime schema +@betterbase/core:test: (pass) IAC Validators (v.*) > v.bytes() creates base64 schema +@betterbase/core:test: (pass) IAC Validators (v.*) > Infer type helper works +@betterbase/core:test: (pass) IAC Schema (defineTable) > defineTable creates table with system fields +@betterbase/core:test: (pass) IAC Schema (defineTable) > defineTable adds index +@betterbase/core:test: (pass) IAC Schema (defineTable) > defineTable adds uniqueIndex +@betterbase/core:test: (pass) IAC Schema (defineTable) > defineTable adds searchIndex +@betterbase/core:test: (pass) IAC Schema (defineTable) > defineTable is chainable +@betterbase/core:test: (pass) IAC Schema (defineSchema) > defineSchema creates schema definition +@betterbase/core:test: (pass) IAC Schema (defineSchema) > InferSchema produces document types [1.00ms] +@betterbase/core:test: (pass) IAC Schema (defineSchema) > Doc type extracts specific table +@betterbase/core:test: (pass) IAC Schema (defineSchema) > TableNames extracts table names +@betterbase/core:test: (pass) Schema Serializer > serializeSchema produces JSON-serializable output +@betterbase/core:test: (pass) Schema Serializer > serializeSchema marks system fields [1.00ms] +@betterbase/core:test: (pass) Schema Serializer > serializeSchema handles v.id() as id:type +@betterbase/core:test: (pass) Schema Serializer > serializeSchema handles v.optional() +@betterbase/core:test: (pass) Schema Diff Engine > diffSchemas from null produces ADD_TABLE for each table +@betterbase/core:test: (pass) Schema Diff Engine > diffSchemas identical schemas produces empty diff [1.00ms] +@betterbase/core:test: (pass) Schema Diff Engine > diffSchemas detects ADD_COLUMN +@betterbase/core:test: (pass) Schema Diff Engine > diffSchemas detects DROP_COLUMN as destructive +@betterbase/core:test: (pass) Schema Diff Engine > diffSchemas detects ALTER_COLUMN as potentially destructive [1.00ms] +@betterbase/core:test: (pass) Schema Diff Engine > diffSchemas detects ADD_INDEX +@betterbase/core:test: (pass) Schema Diff Engine > formatDiff produces human-readable output +@betterbase/core:test: (pass) Function Primitives (query, mutation, action) > query creates query registration +@betterbase/core:test: (pass) Function Primitives (query, mutation, action) > mutation creates mutation registration +@betterbase/core:test: (pass) Function Primitives (query, mutation, action) > action creates action registration [1.00ms] +@betterbase/core:test: (pass) Function Primitives (query, mutation, action) > query validates args +@betterbase/core:test: (pass) Function Primitives (query, mutation, action) > query rejects invalid args +@betterbase/core:test: (pass) DatabaseReader > DatabaseReader has get and query methods +@betterbase/core:test: (pass) DatabaseWriter > DatabaseWriter extends DatabaseReader +@betterbase/core:test: (pass) Function Registry > setFunctionRegistry and getFunctionRegistry +@betterbase/core:test: (pass) Function Registry > lookupFunction finds registered function +@betterbase/core:test: (pass) Function Registry > lookupFunction returns null for unknown path +@betterbase/core:test: (pass) Cron Jobs > cron registers a job [1.00ms] +@betterbase/core:test: (pass) Drizzle Schema Generator > generateDrizzleSchema produces valid code +@betterbase/core:test: (pass) Drizzle Schema Generator > generateDrizzleSchema supports postgres dialect +@betterbase/core:test: (pass) Migration Generator > generateMigration produces valid SQL +@betterbase/core:test: (pass) Migration Generator > generateMigration handles ADD_COLUMN [1.00ms] +@betterbase/core:test: (pass) API Type Generator > generateApiTypes produces declaration file +@betterbase/core:test: (pass) API Type Generator > generateApiTypes groups by kind and file +@betterbase/core:test: +@betterbase/core:test: test/storage.test.ts: +@betterbase/core:test: (pass) Storage Module > createStorage > should return null for null config [1.00ms] +@betterbase/core:test: (pass) Storage Module > createStorage > should return null for undefined config +@betterbase/core:test: (pass) Storage Module > createStorage > should return StorageFactory for valid S3 config [1.00ms] +@betterbase/core:test: (pass) Storage Module > createStorage > should return StorageFactory for valid R2 config [1.00ms] +@betterbase/core:test: (pass) Storage Module > createStorage > should return StorageFactory for valid Backblaze config [1.00ms] +@betterbase/core:test: (pass) Storage Module > createStorage > should return StorageFactory for valid MinIO config +@betterbase/core:test: (pass) Storage Module > createStorage > should throw error for managed provider +@betterbase/core:test: (pass) Storage Module > StorageFactory.from() > should return BucketClient with from() method [1.00ms] +@betterbase/core:test: (pass) Storage Module > StorageFactory.from() > should return BucketClient with all required methods +@betterbase/core:test: (pass) Storage Module > resolveStorageAdapter > should resolve S3 adapter for s3 provider [1.00ms] +@betterbase/core:test: (pass) Storage Module > resolveStorageAdapter > should resolve adapter for R2 provider +@betterbase/core:test: (pass) Storage Module > resolveStorageAdapter > should throw error for managed provider +@betterbase/core:test: (pass) Storage Module > Storage class > should create Storage instance with adapter [1.00ms] +@betterbase/core:test: (pass) Storage Module > Storage class > should return BucketClient from from() [1.00ms] +@betterbase/core:test: (pass) Storage Module > BucketClient operations > BucketClient should have upload method +@betterbase/core:test: (pass) Storage Module > BucketClient operations > BucketClient should have download method [1.00ms] +@betterbase/core:test: (pass) Storage Module > BucketClient operations > BucketClient should have remove method [1.00ms] +@betterbase/core:test: (pass) Storage Module > BucketClient operations > BucketClient should have getPublicUrl method +@betterbase/core:test: (pass) Storage Module > BucketClient operations > BucketClient should have createSignedUrl method [1.00ms] +@betterbase/core:test: (pass) Storage Module > BucketClient operations > BucketClient should have list method +@betterbase/core:test: (pass) Storage Module > Type exports > should export StorageConfig type +@betterbase/core:test: (pass) Storage Module > Type exports > should export StorageFactory interface +@betterbase/core:test: (pass) Storage Module > Type exports > should export BucketClient interface +@betterbase/core:test: (pass) Storage Module > Multiple buckets > should create multiple bucket clients from same storage [1.00ms] +@betterbase/core:test: (pass) Storage Module > Edge cases > should handle empty bucket name [1.00ms] +@betterbase/core:test: (pass) Storage Module > Edge cases > should handle bucket name with special characters +@betterbase/core:test: +@betterbase/core:test: test/graphql-server.test.ts: +@betterbase/core:test: (pass) GraphQL Server > createGraphQLServer > should create server with required config [15.00ms] +@betterbase/core:test: (pass) GraphQL Server > createGraphQLServer > should create server with custom path [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > createGraphQLServer > should create server with auth disabled [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > createGraphQLServer > should create server with playground disabled [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > createGraphQLServer > should create server with custom getUser function [2.00ms] +@betterbase/core:test: (pass) GraphQL Server > createGraphQLServer > should create server with yoga options [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > startGraphQLServer > should be a function +@betterbase/core:test: (pass) GraphQL Server > GraphQLConfig type > should accept minimal config +@betterbase/core:test: (pass) GraphQL Server > GraphQLConfig type > should accept all optional config [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > server structure > should return app with route method [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > server structure > should return yoga server instance [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > server structure > should return HTTP server [1.00ms] +@betterbase/core:test: (pass) GraphQL Server > default configuration > should use default path when not provided [1.00ms] +@betterbase/core:test: +@betterbase/core:test: test/migration.test.ts: +@betterbase/core:test: (pass) migration/index > runMigration > warns when provider does not support RLS +@betterbase/core:test: (pass) migration/index > runMigration > logs info when no policies found [1.00ms] +@betterbase/core:test: (pass) migration/index > runMigration > applies policies when RLS is supported +@betterbase/core:test: (pass) migration/index > runMigration > warns about policy loading errors +@betterbase/core:test: (pass) migration/index > isRLSSupported > returns true for provider that supports RLS +@betterbase/core:test: (pass) migration/index > isRLSSupported > returns false for provider that does not support RLS +@betterbase/core:test: (pass) migration/rls-migrator > applyAuthFunction > executes auth function SQL [1.00ms] +@betterbase/core:test: (pass) migration/rls-migrator > applyAuthFunction > throws when database does not support raw queries +@betterbase/core:test: (pass) migration/rls-migrator > applyPolicies > does nothing for empty policies array +@betterbase/core:test: (pass) migration/rls-migrator > applyPolicies > generates and executes SQL for policies +@betterbase/core:test: (pass) migration/rls-migrator > applyRLSMigration > applies auth function then policies [1.00ms] +@betterbase/core:test: (pass) migration/rls-migrator > dropPolicies > does nothing for empty policies array +@betterbase/core:test: (pass) migration/rls-migrator > dropPolicies > generates and executes DROP SQL for policies +@betterbase/core:test: (pass) migration/rls-migrator > dropTableRLS > drops all policies for a table +@betterbase/core:test: (pass) migration/rls-migrator > getAppliedPolicies > queries pg_policies for applied policies +@betterbase/core:test: (pass) migration/rls-migrator > getAppliedPolicies > throws when database does not support raw queries [1.00ms] +@betterbase/core:test: +@betterbase/core:test: test/rls-auth-bridge.test.ts: +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunction > should generate auth.uid() function +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunction > should be valid SQL [1.00ms] +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunctionWithSetting > should use custom setting name +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunctionWithSetting > should throw for invalid setting name with semicolon +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunctionWithSetting > should throw for invalid setting name with quotes +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunctionWithSetting > should throw for invalid setting name with special chars +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunctionWithSetting > should allow valid setting names with dots and underscores +@betterbase/core:test: (pass) RLS Auth Bridge > generateAuthFunctionWithSetting > should allow alphanumeric setting names [1.00ms] +@betterbase/core:test: (pass) RLS Auth Bridge > dropAuthFunction > should generate DROP FUNCTION statement +@betterbase/core:test: (pass) RLS Auth Bridge > setCurrentUserId > should generate SET statement with user ID +@betterbase/core:test: (pass) RLS Auth Bridge > setCurrentUserId > should escape single quotes in user ID +@betterbase/core:test: (pass) RLS Auth Bridge > setCurrentUserId > should handle UUID format +@betterbase/core:test: (pass) RLS Auth Bridge > setCurrentUserId > should handle numeric user ID as string +@betterbase/core:test: (pass) RLS Auth Bridge > clearCurrentUserId > should generate SET statement to clear user ID +@betterbase/core:test: (pass) RLS Auth Bridge > generateIsAuthenticatedCheck > should generate auth.authenticated() function +@betterbase/core:test: (pass) RLS Auth Bridge > dropIsAuthenticatedCheck > should generate DROP FUNCTION statement +@betterbase/core:test: (pass) RLS Auth Bridge > generateAllAuthFunctions > should return array of auth functions +@betterbase/core:test: (pass) RLS Auth Bridge > generateAllAuthFunctions > should include auth.uid() function +@betterbase/core:test: (pass) RLS Auth Bridge > generateAllAuthFunctions > should include auth.authenticated() function +@betterbase/core:test: (pass) RLS Auth Bridge > dropAllAuthFunctions > should return array of DROP statements +@betterbase/core:test: (pass) RLS Auth Bridge > dropAllAuthFunctions > should include drop for auth.authenticated() +@betterbase/core:test: (pass) RLS Auth Bridge > dropAllAuthFunctions > should include drop for auth.uid() +@betterbase/core:test: (pass) RLS Auth Bridge > SQL generation integration > auth functions should be valid PostgreSQL +@betterbase/core:test: (pass) RLS Auth Bridge > SQL generation integration > generated functions should have proper language specification +@betterbase/core:test: (pass) RLS Auth Bridge > SQL generation integration > SET statements should use LOCAL for session scope +@betterbase/core:test: +@betterbase/core:test: test/graphql.test.ts: +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > generates schema with empty tables object +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > generates schema with single table [1.00ms] +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > generates query type with get and list operations +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > generates mutation type when enabled +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > does not generate mutation type when disabled [1.00ms] +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > generates subscription type when enabled +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > does not generate subscription type when disabled +@betterbase/core:test: (pass) graphql/schema-generator > generateGraphQLSchema > applies type prefix when configured +@betterbase/core:test: (pass) graphql/sdl-exporter > exportSDL > exports empty schema with Query type [1.00ms] +@betterbase/core:test: (pass) graphql/sdl-exporter > exportSDL > exports custom scalars +@betterbase/core:test: (pass) graphql/sdl-exporter > exportSDL > exports mutations when present +@betterbase/core:test: (pass) graphql/sdl-exporter > exportSDL > exports subscriptions when present [1.00ms] +@betterbase/core:test: (pass) graphql/sdl-exporter > exportSDL > respects includeDescriptions option +@betterbase/core:test: (pass) graphql/sdl-exporter > exportSDL > respects sortTypes option +@betterbase/core:test: (pass) graphql/sdl-exporter > exportTypeSDL > exports a specific object type [1.00ms] +@betterbase/core:test: (pass) graphql/sdl-exporter > exportTypeSDL > throws for non-existent type +@betterbase/core:test: (pass) graphql/resolvers > generateResolvers > generates resolvers for empty tables +@betterbase/core:test: (pass) graphql/resolvers > generateResolvers > generates query resolvers +@betterbase/core:test: (pass) graphql/resolvers > generateResolvers > respects mutations config +@betterbase/core:test: (pass) graphql/resolvers > generateResolvers > respects subscriptions config +@betterbase/core:test: +@betterbase/core:test: test/webhooks.test.ts: +@betterbase/core:test: (pass) webhooks/signer > signPayload > signs a string payload [3.00ms] +@betterbase/core:test: (pass) webhooks/signer > signPayload > signs an object payload +@betterbase/core:test: (pass) webhooks/signer > signPayload > same input produces same signature +@betterbase/core:test: (pass) webhooks/signer > signPayload > different secrets produce different signatures +@betterbase/core:test: (pass) webhooks/signer > signPayload > different payloads produce different signatures +@betterbase/core:test: (pass) webhooks/signer > verifySignature > returns true for valid signature +@betterbase/core:test: (pass) webhooks/signer > verifySignature > returns false for invalid signature +@betterbase/core:test: (pass) webhooks/signer > verifySignature > returns false for wrong secret +@betterbase/core:test: (pass) webhooks/signer > verifySignature > returns false for tampered payload +@betterbase/core:test: (pass) webhooks/signer > verifySignature > handles object payloads +@betterbase/core:test: (pass) webhooks/signer > verifySignature > returns false for mismatched signature length +@betterbase/core:test: +@betterbase/core:test: 1 tests skipped: +@betterbase/core:test: (skip) branching - BranchManager > listBranches > sorts by creation date (newest first) +@betterbase/core:test: +@betterbase/core:test: 934 pass +@betterbase/core:test: 1 skip +@betterbase/core:test: 0 fail +@betterbase/core:test: 1506 expect() calls +@betterbase/core:test: Ran 935 tests across 32 files. [2.75s] +@betterbase/cli:test: Resolved, downloaded and extracted [14] +@betterbase/cli:test: Saved lockfile +@betterbase/cli:test: +@betterbase/cli:test: installed better-auth@1.6.9 +@betterbase/cli:test: +@betterbase/cli:test: 22 packages installed [614.00ms] +@betterbase/cli:test: ◆ 📝 Creating auth schema... +@betterbase/cli:test: ◆ Updated src/db/index.ts to export auth-schema +@betterbase/cli:test: ◆ 🔑 Creating auth instance... +@betterbase/cli:test: ◆ 📋 Creating auth types... +@betterbase/cli:test: ◆ 🛡️ Creating auth middleware... +@betterbase/cli:test: ◆ Updated src/index.ts with BetterAuth handler mount +@betterbase/cli:test: ◆ 🗄️ Running database migrations... +@betterbase/cli:test: ◆ Executing drizzle-kit push... +@betterbase/cli:test: No config path provided, using default 'drizzle.config.json' +@betterbase/cli:test: /tmp/bb-auth-xsePUf/drizzle.config.json file does not exist +@betterbase/cli:test: ⚠ Could not run drizzle-kit push automatically: Command failed: bunx drizzle-kit push. Please run it manually. +@betterbase/cli:test: ✓ ✅ BetterAuth setup complete! +@betterbase/cli:test: ◆ Next steps: +@betterbase/cli:test: ◆ 1. Set AUTH_SECRET in .env (already added to .env.example) +@betterbase/cli:test: ◆ 2. Run: bunx drizzle-kit push (if not already run) +@betterbase/cli:test: ◆ 3. Use requireAuth middleware on protected routes: +@betterbase/cli:test: ◆ import { requireAuth } from './middleware/auth' +@betterbase/cli:test: ◆ app.use('*', requireAuth) +@betterbase/cli:test: (pass) runAuthSetupCommand > creates src/auth/index.ts +@betterbase/cli:test: (pass) runAuthSetupCommand > creates src/auth/types.ts +@betterbase/cli:test: (pass) runAuthSetupCommand > creates src/db/auth-schema.ts +@betterbase/cli:test: (pass) runAuthSetupCommand > creates src/middleware/auth.ts +@betterbase/cli:test: (pass) runAuthSetupCommand > middleware contains requireAuth export [5.00ms] +@betterbase/cli:test: (pass) runAuthSetupCommand > middleware contains optionalAuth export [5.00ms] +@betterbase/cli:test: (pass) runAuthSetupCommand > auth-schema.ts contains user and session tables for sqlite [1.00ms] +@betterbase/cli:test: ◆ 🔐 Setting up BetterAuth... +@betterbase/cli:test: ◆ 📦 Installing better-auth... +@betterbase/cli:test: bun add v1.3.13 (bf2e2cec) +@betterbase/cli:test: Resolving dependencies +@betterbase/cli:test: Resolved, downloaded and extracted [0] +@betterbase/cli:test: Saved lockfile +@betterbase/cli:test: +@betterbase/cli:test: installed better-auth@1.6.9 +@betterbase/cli:test: +@betterbase/cli:test: 22 packages installed [115.00ms] +@betterbase/cli:test: ◆ 📝 Creating auth schema... +@betterbase/cli:test: ◆ Updated src/db/index.ts to export auth-schema +@betterbase/cli:test: ◆ 🔑 Creating auth instance... +@betterbase/cli:test: ◆ 📋 Creating auth types... +@betterbase/cli:test: ◆ 🛡️ Creating auth middleware... +@betterbase/cli:test: ◆ Updated src/index.ts with BetterAuth handler mount +@betterbase/cli:test: ◆ 🗄️ Running database migrations... +@betterbase/cli:test: ◆ Executing drizzle-kit push... +@betterbase/cli:test: No config path provided, using default 'drizzle.config.json' +@betterbase/cli:test: /tmp/bb-auth-pg-no1Lrg/drizzle.config.json file does not exist +@betterbase/cli:test: ⚠ Could not run drizzle-kit push automatically: Command failed: bunx drizzle-kit push. Please run it manually. +@betterbase/cli:test: ✓ ✅ BetterAuth setup complete! +@betterbase/cli:test: ◆ Next steps: +@betterbase/cli:test: ◆ 1. Set AUTH_SECRET in .env (already added to .env.example) +@betterbase/cli:test: ◆ 2. Run: bunx drizzle-kit push (if not already run) +@betterbase/cli:test: ◆ 3. Use requireAuth middleware on protected routes: +@betterbase/cli:test: ◆ import { requireAuth } from './middleware/auth' +@betterbase/cli:test: ◆ app.use('*', requireAuth) +@betterbase/cli:test: (pass) runAuthSetupCommand > auth-schema.ts uses pgTable for pg provider [436.00ms] +@betterbase/cli:test: (pass) runAuthSetupCommand > auth/index.ts references the correct provider and betterAuth +@betterbase/cli:test: (pass) runAuthSetupCommand > adds AUTH_SECRET to .env.example +@betterbase/cli:test: (pass) runAuthSetupCommand > mounts auth handler in src/index.ts +@betterbase/cli:test: ◆ ✅ Auth is already set up! +@betterbase/cli:test: ◆ ✅ Auth is already set up! +@betterbase/cli:test: (pass) runAuthSetupCommand > is idempotent — running twice does not duplicate auth handler mount [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/migrate-utils.test.ts: +@betterbase/cli:test: (pass) Migrate Utils > calculateChecksum > should calculate SHA256 checksum of SQL content [4.00ms] +@betterbase/cli:test: (pass) Migrate Utils > calculateChecksum > should produce same checksum for same content +@betterbase/cli:test: (pass) Migrate Utils > calculateChecksum > should produce different checksum for different content +@betterbase/cli:test: (pass) Migrate Utils > calculateChecksum > should trim whitespace before calculating checksum +@betterbase/cli:test: (pass) Migrate Utils > calculateChecksum > should handle empty SQL +@betterbase/cli:test: (pass) Migrate Utils > calculateChecksum > should handle multiline SQL +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should parse valid up migration filename +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should parse valid down migration filename +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should parse migration with complex name +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should return null for invalid filename format +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should return null for filename without direction +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should return null for filename without id +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should return null for filename with invalid direction +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should handle multiple underscores in name +@betterbase/cli:test: (pass) Migrate Utils > parseMigrationFilename > should handle large migration numbers +@betterbase/cli:test: (pass) Migrate Utils > getDatabaseType > should return postgresql for postgres:// URL [2.00ms] +@betterbase/cli:test: (pass) Migrate Utils > getDatabaseType > should return postgresql for postgresql:// URL +@betterbase/cli:test: (pass) Migrate Utils > getDatabaseType > should return postgresql for DB_URL with postgres +@betterbase/cli:test: (pass) Migrate Utils > getDatabaseType > should return sqlite for file paths +@betterbase/cli:test: (pass) Migrate Utils > getDatabaseType > should return sqlite for local database URLs +@betterbase/cli:test: (pass) Migrate Utils > getMigrationsTableSql > should return PostgreSQL migrations table SQL +@betterbase/cli:test: (pass) Migrate Utils > getMigrationsTableSql > should return SQLite migrations table SQL +@betterbase/cli:test: (pass) Migrate Utils > getMigrationsTableSql > should create table with all required columns +@betterbase/cli:test: (pass) Migrate Utils > Integration - loadMigrationFiles > should verify calculateChecksum produces valid output +@betterbase/cli:test: +@betterbase/cli:test: test/smoke.test.ts: +@betterbase/cli:test: (pass) cli > has expected program name [14.00ms] +@betterbase/cli:test: (pass) cli > supports init positional argument [2.00ms] +@betterbase/cli:test: (pass) cli > registers generate crud command [5.00ms] +@betterbase/cli:test: (pass) cli > registers auth setup command [1.00ms] +@betterbase/cli:test: (pass) cli > registers dev command [1.00ms] +@betterbase/cli:test: (pass) cli > registers migrate commands [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/unit/api-client.test.ts: +@betterbase/cli:test: (pass) api-client > requireAuth > returns token and serverUrl when valid credentials exist +@betterbase/cli:test: ✗ Not logged in. Run `bb login` first. +@betterbase/cli:test: (pass) api-client > requireAuth > exits with code 1 when no credentials exist [1.00ms] +@betterbase/cli:test: ✗ Not logged in. Run `bb login` first. +@betterbase/cli:test: (pass) api-client > requireAuth > exits when token is empty string [1.00ms] +@betterbase/cli:test: (pass) api-client > apiRequest > makes authenticated request with valid token [2.00ms] +@betterbase/cli:test: (pass) api-client > apiRequest > throws on non-OK response with JSON body [4.00ms] +@betterbase/cli:test: (pass) api-client > apiRequest > throws HTTP status when non-OK response has JSON body with no error field +@betterbase/cli:test: +@betterbase/cli:test: test/unit/credentials.test.ts: +@betterbase/cli:test: +@betterbase/cli:test: # Unhandled error between tests +@betterbase/cli:test: ------------------------------- +@betterbase/cli:test: 1 | }) +@betterbase/cli:test: 2 | { +@betterbase/cli:test: ^ +@betterbase/cli:test: SyntaxError: Export named 'mkdtempSync' not found in module 'node:os'. +@betterbase/cli:test: at loadAndEvaluateModule (2:1) +@betterbase/cli:test: ------------------------------- +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: test/unit/config.test.ts: +@betterbase/cli:test: (pass) config > findConfigFile > discovers betterbase.config.ts [1.00ms] +@betterbase/cli:test: (pass) config > findConfigFile > discovers betterbase.config.js when .ts not present +@betterbase/cli:test: (pass) config > findConfigFile > discovers betterbase.config.mts when .ts and .js not present +@betterbase/cli:test: (pass) config > findConfigFile > prefers .ts variant over .js and .mts [1.00ms] +@betterbase/cli:test: (pass) config > findConfigFile > returns null when no config file exists +@betterbase/cli:test: +@betterbase/cli:test: test/unit/spinner.test.ts: +@betterbase/cli:test: (pass) spinner > createSpinner > creates an Ora instance +@betterbase/cli:test: - Testing spinner +@betterbase/cli:test: ✓ Done +@betterbase/cli:test: (pass) spinner > withSpinner > calls task and returns result on success [5.00ms] +@betterbase/cli:test: - Testing spinner failure +@betterbase/cli:test: ✗ Failed: task failed +@betterbase/cli:test: (pass) spinner > withSpinner > re-throws error after catching task failure [3.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/unit/auth-providers.test.ts: +@betterbase/cli:test: (pass) auth-providers > PROVIDER_TEMPLATES > has entries for all 7 providers [6.00ms] +@betterbase/cli:test: (pass) auth-providers > PROVIDER_TEMPLATES > each provider has required fields [1.00ms] +@betterbase/cli:test: (pass) auth-providers > PROVIDER_TEMPLATES > each provider config references correct env vars [1.00ms] +@betterbase/cli:test: (pass) auth-providers > PROVIDER_TEMPLATES > each template has correct callback URL pattern +@betterbase/cli:test: (pass) auth-providers > getProviderTemplate > returns template for valid provider name +@betterbase/cli:test: (pass) auth-providers > getProviderTemplate > is case-insensitive [1.00ms] +@betterbase/cli:test: (pass) auth-providers > getProviderTemplate > returns null for unknown provider +@betterbase/cli:test: (pass) auth-providers > getAvailableProviders > returns 7 provider names +@betterbase/cli:test: (pass) auth-providers > getAvailableProviders > includes all expected providers +@betterbase/cli:test: +@betterbase/cli:test: test/e2e/binary-smoke.test.ts: +@betterbase/cli:test: (pass) binary smoke tests > can build the CLI [1021.00ms] +@betterbase/cli:test: (pass) binary smoke tests > bb --version exits with 0 and stdout contains version [755.00ms] +@betterbase/cli:test: (pass) binary smoke tests > bb --help exits with 0 and stdout contains subcommand list [630.00ms] +@betterbase/cli:test: (pass) binary smoke tests > bb init --help exits 0 and contains usage [675.00ms] +@betterbase/cli:test: (pass) binary smoke tests > bb unknown-command exits non-zero [713.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/integration/webhook-commands.test.ts: +@betterbase/cli:test: (pass) runWebhookListCommand > lists all configured webhooks from the config [4.00ms] +@betterbase/cli:test: (pass) runWebhookListCommand > shows message when no webhooks are configured [5.00ms] +@betterbase/cli:test: (pass) runWebhookListCommand > shows disabled webhooks with correct status label [1.00ms] +@betterbase/cli:test: (pass) runWebhookListCommand > shows event types comma separated [1.00ms] +@betterbase/cli:test: (pass) runWebhookListCommand > returns early when config is missing +@betterbase/cli:test: (pass) runWebhookTestCommand > errors when webhook ID is not found in config [1.00ms] +@betterbase/cli:test: (pass) runWebhookTestCommand > errors when URL environment variable is not set [1.00ms] +@betterbase/cli:test: (pass) runWebhookTestCommand > errors when secret environment variable is not set +@betterbase/cli:test: (pass) runWebhookTestCommand > config validation rejects URLs and secrets not using env var references [1.00ms] +@betterbase/cli:test: (pass) runWebhookTestCommand > sends a test payload when all env vars are set [1.00ms] +@betterbase/cli:test: (pass) runWebhookTestCommand > reports failure when test webhook returns success: false [1.00ms] +@betterbase/cli:test: (pass) runWebhookLogsCommand > displays delivery logs from the local database [11.00ms] +@betterbase/cli:test: (pass) runWebhookLogsCommand > shows message when no delivery logs exist [2.00ms] +@betterbase/cli:test: (pass) runWebhookLogsCommand > shows error when the database file does not exist [1.00ms] +@betterbase/cli:test: (pass) runWebhookLogsCommand > errors when webhook ID is not found [1.00ms] +@betterbase/cli:test: (pass) runWebhookLogsCommand > respects the limit option when querying logs [8.00ms] +@betterbase/cli:test: (pass) runWebhookCommand routing > routes 'list' to list command and shows webhooks [3.00ms] +@betterbase/cli:test: (pass) runWebhookCommand routing > routes 'test' to test command [1.00ms] +@betterbase/cli:test: (pass) runWebhookCommand routing > routes 'logs' to logs command [1.00ms] +@betterbase/cli:test: (pass) runWebhookCommand routing > shows help when no subcommand is provided +@betterbase/cli:test: (pass) runWebhookCommand routing > shows usage error when 'test' has no webhook ID +@betterbase/cli:test: (pass) runWebhookCommand routing > shows usage error when 'logs' has no webhook ID [1.00ms] +@betterbase/cli:test: 746 | const { runWebhookCreateCommand } = await import("../../src/commands/webhook"); +@betterbase/cli:test: 747 | await runWebhookCreateCommand(t.root); +@betterbase/cli:test: 748 | +@betterbase/cli:test: 749 | const output = captured.lines.join("\n"); +@betterbase/cli:test: 750 | // Should contain success message with webhook ID +@betterbase/cli:test: 751 | expect(output).toMatch(/Webhook created with ID:\s+webhook-[0-9a-z]+/); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toMatch(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected substring or pattern: /Webhook created with ID:\s+webhook-[0-9a-z]+/ +@betterbase/cli:test: Received: "✗ No tables found in schema. Please define tables in src/db/schema.ts first." +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/webhook-commands.test.ts:751:22) +@betterbase/cli:test: (fail) generateWebhookId via runWebhookCreateCommand > creates a webhook ID with correct prefix and logs it [1.00ms] +@betterbase/cli:test: 765 | try { +@betterbase/cli:test: 766 | const { runWebhookCreateCommand } = await import("../../src/commands/webhook"); +@betterbase/cli:test: 767 | await runWebhookCreateCommand(t.root); +@betterbase/cli:test: 768 | const output = captured.lines.join("\n"); +@betterbase/cli:test: 769 | const match = output.match(/webhook-[0-9a-z]+/); +@betterbase/cli:test: 770 | expect(match).not.toBeNull(); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).not.toBeNull() +@betterbase/cli:test: +@betterbase/cli:test: Received: null +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/webhook-commands.test.ts:770:27) +@betterbase/cli:test: (fail) generateWebhookId via runWebhookCreateCommand > produces unique IDs across calls [1.00ms] +@betterbase/cli:test: 792 | try { +@betterbase/cli:test: 793 | const { runWebhookCreateCommand } = await import("../../src/commands/webhook"); +@betterbase/cli:test: 794 | await runWebhookCreateCommand(t.root); +@betterbase/cli:test: 795 | const output = captured.lines.join("\n"); +@betterbase/cli:test: 796 | const match = output.match(/webhook-[0-9a-z]+/); +@betterbase/cli:test: 797 | expect(match).not.toBeNull(); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).not.toBeNull() +@betterbase/cli:test: +@betterbase/cli:test: Received: null +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/webhook-commands.test.ts:797:27) +@betterbase/cli:test: (fail) generateWebhookId via runWebhookCreateCommand > IDs are monotonically increasing with time [1.00ms] +@betterbase/cli:test: 817 | if (projectRoot) cleanupProject(projectRoot); +@betterbase/cli:test: 818 | }); +@betterbase/cli:test: 819 | +@betterbase/cli:test: 820 | it("returns early when config file does not exist", async () => { +@betterbase/cli:test: 821 | projectRoot = createProject({}); +@betterbase/cli:test: 822 | captured = captureConsole(); +@betterbase/cli:test: ^ +@betterbase/cli:test: ReferenceError: captured is not defined +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/webhook-commands.test.ts:822:5) +@betterbase/cli:test: (fail) runWebhookCreateCommand helpers > returns early when config file does not exist +@betterbase/cli:test: +@betterbase/cli:test: test/integration/rls-test-command.test.ts: +@betterbase/cli:test: (pass) RLS Test Command > RLSTestCase type > has correct shape with all required fields [1.00ms] +@betterbase/cli:test: (pass) RLS Test Command > RLSTestCase type > supports optional expectedRowCount field on blocked tests +@betterbase/cli:test: (pass) RLS Test Command > RLSTestResult type > has correct shape with all fields +@betterbase/cli:test: (pass) RLS Test Command > RLSTestResult type > includes optional error field for failure results +@betterbase/cli:test: (pass) RLS Test Command > getDatabaseUrl > returns DATABASE_URL when set in env [3.00ms] +@betterbase/cli:test: (pass) RLS Test Command > getDatabaseUrl > throws when DATABASE_URL is not set +@betterbase/cli:test: (pass) RLS Test Command > loadTablePolicies > returns defaults when no policies directory exists [1.00ms] +@betterbase/cli:test: (pass) RLS Test Command > loadTablePolicies > reads policy files correctly and extracts operations [1.00ms] +@betterbase/cli:test: (pass) RLS Test Command > loadTablePolicies > returns defaults when no matching .policy.ts files found for table [1.00ms] +@betterbase/cli:test: (pass) RLS Test Command > generatePolicySQL > generates CREATE POLICY for select only [1.00ms] +@betterbase/cli:test: (pass) RLS Test Command > generatePolicySQL > generates CREATE POLICY for select + insert [1.00ms] +@betterbase/cli:test: (pass) RLS Test Command > generatePolicySQL > generates CREATE POLICY for all operations +@betterbase/cli:test: (pass) RLS Test Command > generatePolicySQL > returns empty string when policy file has no operations [1.00ms] +@betterbase/cli:test: (pass) RLS Test Command > runRLSTestCommand database type validation > rejects non-PostgreSQL database type +@betterbase/cli:test: (pass) RLS Test Command > runRLSTestCommand database type validation > throws when DATABASE_URL is missing [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/integration/init.test.ts: +@betterbase/cli:test: (pass) projectNameSchema > accepts valid names (alphanumeric, hyphens, underscores) [1.00ms] +@betterbase/cli:test: (pass) projectNameSchema > rejects empty strings +@betterbase/cli:test: (pass) projectNameSchema > rejects names with special characters (spaces, @, !, etc.) +@betterbase/cli:test: (pass) projectNameSchema > trims whitespace before validation +@betterbase/cli:test: (pass) initOptionsSchema > accepts empty object +@betterbase/cli:test: (pass) initOptionsSchema > accepts object with valid projectName +@betterbase/cli:test: (pass) initOptionsSchema > accepts object with iac flag +@betterbase/cli:test: (pass) initOptionsSchema > accepts object with both projectName and iac +@betterbase/cli:test: (pass) initOptionsSchema > rejects object with invalid projectName [1.00ms] +@betterbase/cli:test: (pass) providerTypeSchema > accepts all valid provider types +@betterbase/cli:test: (pass) providerTypeSchema > rejects invalid provider types +@betterbase/cli:test: (pass) getDatabaseLabel > returns correct label for neon +@betterbase/cli:test: (pass) getDatabaseLabel > returns correct label for turso +@betterbase/cli:test: (pass) getDatabaseLabel > returns correct label for planetscale +@betterbase/cli:test: (pass) getDatabaseLabel > returns correct label for supabase +@betterbase/cli:test: (pass) getDatabaseLabel > returns correct label for postgres +@betterbase/cli:test: (pass) getDatabaseLabel > returns correct label for managed +@betterbase/cli:test: (pass) getDatabaseLabel > every known provider has a distinct label [1.00ms] +@betterbase/cli:test: (pass) getAuthDialect > returns sqlite for turso +@betterbase/cli:test: (pass) getAuthDialect > returns mysql for planetscale +@betterbase/cli:test: (pass) getAuthDialect > returns pg for neon +@betterbase/cli:test: (pass) getAuthDialect > returns pg for postgres +@betterbase/cli:test: (pass) getAuthDialect > returns pg for supabase +@betterbase/cli:test: (pass) getAuthDialect > returns pg for managed +@betterbase/cli:test: (pass) InitCommandOptions > allows empty object +@betterbase/cli:test: (pass) InitCommandOptions > allows projectName string +@betterbase/cli:test: (pass) InitCommandOptions > allows iac boolean flag +@betterbase/cli:test: (pass) InitCommandOptions > validation rejects invalid projectName via initOptionsSchema +@betterbase/cli:test: (pass) InitCommandOptions > validation passes with valid combined options +@betterbase/cli:test: 327 | expect(existsSync(join(projectPath, "src", "modules", "README.md"))).toBe(true); +@betterbase/cli:test: 328 | expect(existsSync(join(projectPath, "src", "modules", ".gitkeep"))).toBe(true); +@betterbase/cli:test: 329 | +@betterbase/cli:test: 330 | // Spot-check contents +@betterbase/cli:test: 331 | const pkg = JSON.parse(readFileSync(join(projectPath, "package.json"), "utf-8")); +@betterbase/cli:test: 332 | expect(pkg.name).toBe(projectName); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: "bb-test-c3d7a73a" +@betterbase/cli:test: Received: "my-betterbase-project" +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/init.test.ts:332:21) +@betterbase/cli:test: (fail) runInitCommand (IaC integration) > copies full IaC template into new project directory [6.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/integration/rls-commands.test.ts: +@betterbase/cli:test: (pass) runRlsCreate > creates a .policy.ts file with correct template [2.00ms] +@betterbase/cli:test: (pass) runRlsCreate > sanitizes table name (special chars → underscores) +@betterbase/cli:test: (pass) runRlsCreate > sanitizes table name with spaces [1.00ms] +@betterbase/cli:test: (pass) runRlsCreate > warns on duplicate policy +@betterbase/cli:test: (pass) runRlsCreate > throws on missing table name (empty string) [1.00ms] +@betterbase/cli:test: (pass) runRlsCreate > throws on missing table name (undefined) +@betterbase/cli:test: (pass) runRlsList > lists multiple policies [1.00ms] +@betterbase/cli:test: (pass) runRlsList > displays correct count [1.00ms] +@betterbase/cli:test: (pass) runRlsList > handles empty/no policies directory +@betterbase/cli:test: (pass) runRlsList > handles existing but empty policies directory +@betterbase/cli:test: (pass) runRlsList > ignores non-policy files in the directory [1.00ms] +@betterbase/cli:test: (pass) runRlsDisable > shows delete instructions when policy exists [1.00ms] +@betterbase/cli:test: (pass) runRlsDisable > handles missing policy (no policy file found) +@betterbase/cli:test: (pass) runRlsDisable > throws on missing table name (empty string) +@betterbase/cli:test: (pass) runRlsDisable > throws on missing table name (undefined) [1.00ms] +@betterbase/cli:test: (pass) runRlsCommand > routes 'create' to runRlsCreate +@betterbase/cli:test: (pass) runRlsCommand > routes 'list' to runRlsList [2.00ms] +@betterbase/cli:test: (pass) runRlsCommand > routes 'disable' to runRlsDisable +@betterbase/cli:test: (pass) runRlsCommand > shows help when no subcommand given (empty array) +@betterbase/cli:test: (pass) runRlsCommand > shows help for unknown subcommand [1.00ms] +@betterbase/cli:test: (pass) runRlsCommand > shows help for undefined subcommand +@betterbase/cli:test: +@betterbase/cli:test: test/integration/dev.test.ts: +@betterbase/cli:test: (pass) runDevCommand > creates cleanup function [1.00ms] +@betterbase/cli:test: (pass) runDevCommand > detects betterbase/ directory [1.00ms] +@betterbase/cli:test: (pass) runDevCommand > handles missing betterbase/ gracefully +@betterbase/cli:test: (pass) runDevCommand > QUERY_LOG=true enables query log [1.00ms] +@betterbase/cli:test: (pass) runDevCommand > QUERY_LOG=false disables query log [1.00ms] +@betterbase/cli:test: (pass) runDevCommand > validates project root exists +@betterbase/cli:test: (pass) runDevCommand > cleanup function can be called without error +@betterbase/cli:test: (pass) runDevCommand > handles missing schema gracefully [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/integration/cross-product-workflow.test.ts: +@betterbase/cli:test: - Generating migration files... +@betterbase/cli:test: ✓ Migration files generated +@betterbase/cli:test: - Applying migration changes... +@betterbase/cli:test: ✓ Applied migration changes +@betterbase/cli:test: 106 | throw new Error( +@betterbase/cli:test: 107 | `Migration conflict detected during push. Please resolve and retry.\n${push.stderr}`, +@betterbase/cli:test: 108 | ); +@betterbase/cli:test: 109 | } +@betterbase/cli:test: 110 | +@betterbase/cli:test: 111 | throw new Error(`Migration push failed.\n${push.stderr || push.stdout}`); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: Migration push failed. +@betterbase/cli:test: No config path provided, using default 'drizzle.config.ts' +@betterbase/cli:test: Reading config file '/tmp/bb-test-aacaa92b/drizzle.config.ts' +@betterbase/cli:test: Error Please provide required params: +@betterbase/cli:test: [x] url: undefined +@betterbase/cli:test: +@betterbase/cli:test: at runMigrateCommand (/workspaces/Betterbase/packages/cli/src/commands/migrate.ts:111:13) +@betterbase/cli:test: at async (/workspaces/Betterbase/packages/cli/test/integration/cross-product-workflow.test.ts:75:10) +@betterbase/cli:test: (fail) Cross-Product Integration Pipeline (real implementations) > migrate generates migrations and GraphQL schema, then context builds on them [611.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/integration/branch-commands.test.ts: +@betterbase/cli:test: (pass) runBranchCreateCommand > throws when branch name is not provided [1.00ms] +@betterbase/cli:test: (pass) runBranchCreateCommand > throws when config file cannot be loaded +@betterbase/cli:test: (pass) runBranchCreateCommand > creates a branch successfully with a valid name and config +@betterbase/cli:test: (pass) runBranchCreateCommand > throws when branch creation fails (success: false) [1.00ms] +@betterbase/cli:test: (pass) runBranchListCommand > throws when config file cannot be loaded +@betterbase/cli:test: (pass) runBranchListCommand > lists branches when config is valid and branches exist +@betterbase/cli:test: (pass) runBranchListCommand > shows empty state message when no branches exist [1.00ms] +@betterbase/cli:test: (pass) runBranchDeleteCommand > throws when branch name is not provided +@betterbase/cli:test: (pass) runBranchDeleteCommand > throws when config file cannot be loaded +@betterbase/cli:test: (pass) runBranchDeleteCommand > throws when branch name is not found +@betterbase/cli:test: (pass) runBranchDeleteCommand > deletes an existing branch successfully [1.00ms] +@betterbase/cli:test: (pass) runBranchDeleteCommand > throws when delete operation fails +@betterbase/cli:test: (pass) runBranchSleepCommand > throws when branch name is not provided [1.00ms] +@betterbase/cli:test: (pass) runBranchSleepCommand > throws when config file cannot be loaded +@betterbase/cli:test: (pass) runBranchSleepCommand > throws when branch name is not found +@betterbase/cli:test: (pass) runBranchSleepCommand > puts a branch to sleep successfully +@betterbase/cli:test: (pass) runBranchSleepCommand > throws when sleep operation fails +@betterbase/cli:test: (pass) runBranchWakeCommand > throws when branch name is not provided [1.00ms] +@betterbase/cli:test: (pass) runBranchWakeCommand > throws when config file cannot be loaded +@betterbase/cli:test: (pass) runBranchWakeCommand > throws when branch name is not found +@betterbase/cli:test: (pass) runBranchWakeCommand > wakes a sleeping branch successfully +@betterbase/cli:test: (pass) runBranchWakeCommand > throws when wake operation fails +@betterbase/cli:test: (pass) runBranchCommand routing > "create" subcommand > dispatches to runBranchCreateCommand [1.00ms] +@betterbase/cli:test: (pass) runBranchCommand routing > "create" subcommand > re-throws errors from create (e.g. missing name) +@betterbase/cli:test: (pass) runBranchCommand routing > "list" and "ls" subcommands > dispatches "list" to runBranchListCommand +@betterbase/cli:test: (pass) runBranchCommand routing > "list" and "ls" subcommands > dispatches "ls" alias to runBranchListCommand +@betterbase/cli:test: (pass) runBranchCommand routing > "list" and "ls" subcommands > re-throws errors from list (e.g. missing config) +@betterbase/cli:test: (pass) runBranchCommand routing > "delete", "remove", and "rm" subcommands > dispatches "delete" to runBranchDeleteCommand +@betterbase/cli:test: (pass) runBranchCommand routing > "delete", "remove", and "rm" subcommands > dispatches "remove" alias to runBranchDeleteCommand [1.00ms] +@betterbase/cli:test: (pass) runBranchCommand routing > "delete", "remove", and "rm" subcommands > dispatches "rm" alias to runBranchDeleteCommand +@betterbase/cli:test: (pass) runBranchCommand routing > "delete", "remove", and "rm" subcommands > re-throws errors from delete (e.g. missing name) +@betterbase/cli:test: (pass) runBranchCommand routing > "sleep" subcommand > dispatches to runBranchSleepCommand +@betterbase/cli:test: (pass) runBranchCommand routing > "sleep" subcommand > re-throws errors from sleep (e.g. missing name) +@betterbase/cli:test: (pass) runBranchCommand routing > "wake" subcommand > dispatches to runBranchWakeCommand +@betterbase/cli:test: (pass) runBranchCommand routing > "wake" subcommand > re-throws errors from wake (e.g. missing name) +@betterbase/cli:test: (pass) runBranchCommand routing > no subcommand > shows help without throwing +@betterbase/cli:test: (pass) runBranchCommand routing > no subcommand > shows help when args are undefined [1.00ms] +@betterbase/cli:test: (pass) runBranchCommand routing > unknown subcommand > throws for unrecognized subcommand +@betterbase/cli:test: (pass) runBranchCommand routing > unknown subcommand > throws for any random string +@betterbase/cli:test: +@betterbase/cli:test: test/integration/function-commands.test.ts: +@betterbase/cli:test: (pass) runFunctionCommand create > creates a function directory with index.ts and config.ts [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand create > creates a function with hyphens and underscores in name +@betterbase/cli:test: (pass) runFunctionCommand create > rejects names with special characters [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand create > rejects names with spaces [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand create > rejects names with dots (e.g. path traversal) +@betterbase/cli:test: (pass) runFunctionCommand create > rejects missing function name +@betterbase/cli:test: (pass) runFunctionCommand create > rejects duplicate function name [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand create > index.ts template contains POST handler +@betterbase/cli:test: (pass) runFunctionCommand list > lists functions with proper table format [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand list > shows 'not built' status for unbuilt functions +@betterbase/cli:test: (pass) runFunctionCommand list > shows message when no functions exist +@betterbase/cli:test: (pass) runFunctionCommand list > calls isFunctionBuilt for each function +@betterbase/cli:test: (pass) runFunctionCommand list > handles mixed built/not-built status across functions [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand build > builds a function successfully +@betterbase/cli:test: (pass) runFunctionCommand build > reports errors when build fails [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand build > rejects missing function name +@betterbase/cli:test: (pass) runFunctionCommand build > handles bundle with multiple errors +@betterbase/cli:test: (pass) runFunctionCommand deploy > rejects missing function name [2.00ms] +@betterbase/cli:test: (pass) runFunctionCommand deploy > errors when function directory does not exist [1.00ms] +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/cf-func.js +@betterbase/cli:test: - Deploying to edge... +@betterbase/cli:test: ✓ Deployed cf-func +@betterbase/cli:test: (pass) runFunctionCommand deploy > deploys to cloudflare-workers successfully [1.00ms] +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/vc-func.js +@betterbase/cli:test: - Deploying to edge... +@betterbase/cli:test: ✓ Deployed vc-func +@betterbase/cli:test: (pass) runFunctionCommand deploy > deploys to vercel-edge successfully [1.00ms] +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/broken-deploy.js +@betterbase/cli:test: (pass) runFunctionCommand deploy > reports build failure during deploy +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/fail-deploy.js +@betterbase/cli:test: - Deploying to edge... +@betterbase/cli:test: (pass) runFunctionCommand deploy > handles deployment failure after successful build [1.00ms] +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/sync-func.js +@betterbase/cli:test: - Deploying to edge... +@betterbase/cli:test: ✓ Deployed sync-func +@betterbase/cli:test: (pass) runFunctionCommand deploy > calls syncEnvToCloudflare when --sync-env flag is passed [1.00ms] +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/missing-env.js +@betterbase/cli:test: - Deploying to edge... +@betterbase/cli:test: ✓ Deployed missing-env +@betterbase/cli:test: (pass) runFunctionCommand deploy > warns about missing env vars in .env when syncing [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand logs > rejects missing function name +@betterbase/cli:test: (pass) runFunctionCommand logs > fetches and displays cloudflare worker logs [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand logs > shows error when cloudflare logs fetch fails +@betterbase/cli:test: (pass) runFunctionCommand logs > fetches and displays vercel edge logs +@betterbase/cli:test: (pass) runFunctionCommand logs > shows error when vercel logs fetch fails [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand routing > routes "create" to function creation +@betterbase/cli:test: (pass) runFunctionCommand routing > routes "list" to function listing +@betterbase/cli:test: (pass) runFunctionCommand routing > routes "build" to function building [1.00ms] +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/dep-func.js +@betterbase/cli:test: - Deploying to edge... +@betterbase/cli:test: ✓ Deployed dep-func +@betterbase/cli:test: (pass) runFunctionCommand routing > routes "deploy" to function deployment +@betterbase/cli:test: (pass) runFunctionCommand routing > routes "logs" to function logs [1.00ms] +@betterbase/cli:test: (pass) runFunctionCommand routing > shows help for unknown action +@betterbase/cli:test: (pass) runFunctionCommand routing > shows help when no action is provided (empty args) +@betterbase/cli:test: - Bundling function... +@betterbase/cli:test: ✓ Bundled dist/route-sync.js +@betterbase/cli:test: - Deploying to edge... +@betterbase/cli:test: ✓ Deployed route-sync +@betterbase/cli:test: (pass) runFunctionCommand routing > handles deploy with --sync-env flag via routing [1.00ms] +@betterbase/cli:test: (pass) stopAllFunctions > completes without error when no functions are running +@betterbase/cli:test: (pass) stopAllFunctions > does not throw on subsequent calls +@betterbase/cli:test: +@betterbase/cli:test: test/integration/storage-commands.test.ts: +@betterbase/cli:test: (pass) runStorageBucketsListCommand > errors when storage is not configured (no config, no env) [3.00ms] +@betterbase/cli:test: (pass) runStorageBucketsListCommand > lists objects when config and env credentials are provided [1.00ms] +@betterbase/cli:test: (pass) runStorageBucketsListCommand > shows empty bucket message when bucket has no objects [1.00ms] +@betterbase/cli:test: (pass) runStorageBucketsListCommand > errors when config exists but credentials are missing from env +@betterbase/cli:test: (pass) runStorageBucketsListCommand > works with env-only config (getStorageConfigFromEnv path) +@betterbase/cli:test: (pass) runStorageBucketsListCommand > returns null when STORAGE_BUCKET is missing from env config [1.00ms] +@betterbase/cli:test: (pass) runStorageBucketsListCommand > handles adapter errors gracefully +@betterbase/cli:test: (pass) runStorageUploadCommand > errors when file path is empty [1.00ms] +@betterbase/cli:test: (pass) runStorageUploadCommand > errors when file does not exist +@betterbase/cli:test: (pass) runStorageUploadCommand > uploads file and displays details including formatBytes output [1.00ms] +@betterbase/cli:test: (pass) runStorageUploadCommand > determines correct content type from file extension +@betterbase/cli:test: (pass) runStorageUploadCommand > errors when storage is not configured [1.00ms] +@betterbase/cli:test: (pass) runStorageUploadCommand > errors when config exists but credentials are missing +@betterbase/cli:test: (pass) runStorageUploadCommand > handles upload adapter errors [1.00ms] +@betterbase/cli:test: (pass) runStorageUploadCommand > uses custom bucket option when provided +@betterbase/cli:test: (pass) runStorageUploadCommand > uses custom remote path when provided [1.00ms] +@betterbase/cli:test: (pass) runStorageUploadCommand > resolves absolute file paths correctly [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/integration/login-commands.test.ts: +@betterbase/cli:test: +@betterbase/cli:test: # Unhandled error between tests +@betterbase/cli:test: ------------------------------- +@betterbase/cli:test: 1 | }) +@betterbase/cli:test: 2 | { +@betterbase/cli:test: ^ +@betterbase/cli:test: SyntaxError: Export named 'mkdtempSync' not found in module 'node:os'. +@betterbase/cli:test: at loadAndEvaluateModule (2:1) +@betterbase/cli:test: ------------------------------- +@betterbase/cli:test: +@betterbase/cli:test: +@betterbase/cli:test: test/integration/iac-workflow.test.ts: +@betterbase/cli:test: 53 | +@betterbase/cli:test: 54 | it("iac sync generates schema.json and drizzle migrations", async () => { +@betterbase/cli:test: 55 | const proj = makeProject(); +@betterbase/cli:test: 56 | try { +@betterbase/cli:test: 57 | await runIacSync(proj.root); +@betterbase/cli:test: 58 | expect(existsSync(join(proj.root, "betterbase/_generated/schema.json"))).toBe(true); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: true +@betterbase/cli:test: Received: false +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/iac-workflow.test.ts:58:77) +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > iac sync generates schema.json and drizzle migrations +@betterbase/cli:test: 64 | +@betterbase/cli:test: 65 | it("iac generate creates api.d.ts", async () => { +@betterbase/cli:test: 66 | const proj = makeProject(); +@betterbase/cli:test: 67 | try { +@betterbase/cli:test: 68 | await runIacGenerate(proj.root); +@betterbase/cli:test: 69 | expect(existsSync(join(proj.root, "betterbase/_generated/api.d.ts"))).toBe(true); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: true +@betterbase/cli:test: Received: false +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/iac-workflow.test.ts:69:74) +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > iac generate creates api.d.ts [1.00ms] +@betterbase/cli:test: 82 | export const getUsers = query((c) => c.table("user").select());`, +@betterbase/cli:test: 83 | ); +@betterbase/cli:test: 84 | const captured = captureConsole(); +@betterbase/cli:test: 85 | try { +@betterbase/cli:test: 86 | await runIacAnalyze(proj.root); +@betterbase/cli:test: 87 | expect(captured.lines.some((l) => l.includes("getUsers") || l.includes("scanned"))).toBe(true); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: true +@betterbase/cli:test: Received: false +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/iac-workflow.test.ts:87:89) +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > iac analyze scans queries and outputs analysis +@betterbase/cli:test: 96 | it("full pipeline: sync → generate → analyze", async () => { +@betterbase/cli:test: 97 | const proj = makeProject(); +@betterbase/cli:test: 98 | try { +@betterbase/cli:test: 99 | await runIacSync(proj.root); +@betterbase/cli:test: 100 | await runIacGenerate(proj.root); +@betterbase/cli:test: 101 | expect(existsSync(join(proj.root, "betterbase/_generated/api.d.ts"))).toBe(true); +@betterbase/cli:test: ^ +@betterbase/cli:test: error: expect(received).toBe(expected) +@betterbase/cli:test: +@betterbase/cli:test: Expected: true +@betterbase/cli:test: Received: false +@betterbase/cli:test: +@betterbase/cli:test: at (/workspaces/Betterbase/packages/cli/test/integration/iac-workflow.test.ts:101:74) +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > full pipeline: sync → generate → analyze [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: test/cli/cli-parsing.test.ts: +@betterbase/cli:test: (pass) CLI argument parsing regression > top-level program > has name 'bb' +@betterbase/cli:test: (pass) CLI argument parsing regression > top-level program > has --debug option +@betterbase/cli:test: (pass) CLI argument parsing regression > top-level program > has --version option +@betterbase/cli:test: (pass) CLI argument parsing regression > top-level program > uses .exitOverride() for CommanderError instead of process.exit +@betterbase/cli:test: (pass) CLI argument parsing regression > init > registers init command +@betterbase/cli:test: (pass) CLI argument parsing regression > init > has optional project-name argument +@betterbase/cli:test: (pass) CLI argument parsing regression > init > has --no-iac option +@betterbase/cli:test: (pass) CLI argument parsing regression > auth > registers auth command +@betterbase/cli:test: (pass) CLI argument parsing regression > auth > auth setup > registers setup subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > auth > auth setup > has optional project-root argument with cwd default +@betterbase/cli:test: (pass) CLI argument parsing regression > auth > auth add-provider > registers add-provider subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > auth > auth add-provider > has required provider argument +@betterbase/cli:test: (pass) CLI argument parsing regression > auth > auth add-provider > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > generate > registers generate command +@betterbase/cli:test: (pass) CLI argument parsing regression > generate > generate crud > registers crud subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > generate > generate crud > has required table-name argument +@betterbase/cli:test: (pass) CLI argument parsing regression > generate > generate crud > has optional project-root argument [1.00ms] +@betterbase/cli:test: (pass) CLI argument parsing regression > graphql > registers graphql command +@betterbase/cli:test: (pass) CLI argument parsing regression > graphql > graphql generate > registers generate subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > graphql > graphql generate > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > graphql > graphql playground > registers playground subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > registers iac command +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac sync > registers sync subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac sync > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac sync > has --force option +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac analyze > registers analyze subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac analyze > has -o, --output option with default 'table' +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac analyze > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac export > registers export subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac export > has -f, --format option with default 'json' +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac export > has -o, --output option with default './backup' +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac export > has -t, --table option +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac export > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac import > registers import subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac import > has required input argument +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac import > has -t, --table option +@betterbase/cli:test: (pass) CLI argument parsing regression > iac > iac import > has -d, --dry-run option +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > registers migrate command +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > migrate preview > registers preview subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > migrate production > registers production subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > migrate rollback > registers rollback subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > migrate rollback > has -s, --steps option with default '1' +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > migrate from-convex > registers from-convex subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > migrate from-convex > has required input-path argument +@betterbase/cli:test: (pass) CLI argument parsing regression > migrate > migrate from-convex > has -o, --output option with default './migrated' +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > registers storage command +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > storage init > registers init subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > storage init > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > storage upload > registers upload subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > storage upload > has required file argument +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > storage upload > has -b, --bucket option +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > storage upload > has -p, --path option +@betterbase/cli:test: (pass) CLI argument parsing regression > storage > storage upload > has -r, --root option with cwd default [1.00ms] +@betterbase/cli:test: (pass) CLI argument parsing regression > rls > registers rls command +@betterbase/cli:test: (pass) CLI argument parsing regression > rls > rls create > registers create subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > rls > rls create > has required table argument +@betterbase/cli:test: (pass) CLI argument parsing regression > rls > rls disable > registers disable subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > rls > rls disable > has required table argument +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > registers webhook command +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook create > registers create subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook create > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook test > registers test subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook test > has required webhook-id argument +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook test > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook logs > registers logs subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook logs > has required webhook-id argument +@betterbase/cli:test: (pass) CLI argument parsing regression > webhook > webhook logs > has -l, --limit option with default '50' +@betterbase/cli:test: (pass) CLI argument parsing regression > function > registers function command +@betterbase/cli:test: (pass) CLI argument parsing regression > function > function create > registers create subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > function > function create > has required name argument +@betterbase/cli:test: (pass) CLI argument parsing regression > function > function create > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > function > function deploy > registers deploy subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > function > function deploy > has required name argument +@betterbase/cli:test: (pass) CLI argument parsing regression > function > function deploy > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > function > function deploy > has --sync-env option +@betterbase/cli:test: (pass) CLI argument parsing regression > branch > registers branch command +@betterbase/cli:test: (pass) CLI argument parsing regression > branch > branch create > registers create subcommand +@betterbase/cli:test: (pass) CLI argument parsing regression > branch > branch create > has required name argument +@betterbase/cli:test: (pass) CLI argument parsing regression > branch > branch create > has optional project-root argument +@betterbase/cli:test: (pass) CLI argument parsing regression > login > registers login command +@betterbase/cli:test: (pass) CLI argument parsing regression > login > has --url option with default 'https://api.betterbase.io' +@betterbase/cli:test: (pass) CLI argument parsing regression > login > has --email option [1.00ms] +@betterbase/cli:test: (pass) CLI argument parsing regression > help text > contains expected usage info [2.00ms] +@betterbase/cli:test: (pass) CLI argument parsing regression > help text > lists init command [1.00ms] +@betterbase/cli:test: (pass) CLI argument parsing regression > help text > lists migrate command +@betterbase/cli:test: +@betterbase/cli:test: bb — Betterbase CLI +@betterbase/cli:test: +@betterbase/cli:test: Manage projects, schema, functions, and deployments. +@betterbase/cli:test: +@betterbase/cli:test: Usage: bb [options] [command] +@betterbase/cli:test: +@betterbase/cli:test: BetterBase CLI +@betterbase/cli:test: +@betterbase/cli:test: Options: +@betterbase/cli:test: -v, --version display the CLI version +@betterbase/cli:test: (pass) CLI argument parsing regression > parseAsync --help > throws CommanderError with code commander.helpDisplayed [2.00ms] +@betterbase/cli:test: --debug Show full error stack traces +@betterbase/cli:test: -h, --help display help for command +@betterbase/cli:test: +@betterbase/cli:test: Commands: +@betterbase/cli:test: auth Authentication helpers +@betterbase/cli:test: branch Preview environment (branch) management +@betterbase/cli:test: dev Watch schema/routes and regenerate .betterbase-context.json +@betterbase/cli:test: function Edge function management +@betterbase/cli:test: generate Code generation helpers +@betterbase/cli:test: graphql GraphQL API management +@betterbase/cli:test: help display help for command +@betterbase/cli:test: iac IaC (Infrastructure as Code) management +@betterbase/cli:test: init Initialize a BetterBase project with BetterBase template +@betterbase/cli:test: (betterbase/ functions) +@betterbase/cli:test: login Authenticate with a Betterbase instance +@betterbase/cli:test: logout Sign out of Betterbase +@betterbase/cli:test: migrate Generate and apply migrations for local development +@betterbase/cli:test: rls Row Level Security policy management +@betterbase/cli:test: storage Storage management +@betterbase/cli:test: webhook Webhook management +@betterbase/cli:test: +@betterbase/cli:test: Examples: +@betterbase/cli:test: $ bb init my-app +@betterbase/cli:test: $ bb dev +@betterbase/cli:test: $ bb iac sync +@betterbase/cli:test: $ bb login --url http://localhost:3001 +@betterbase/cli:test: +@betterbase/cli:test: Docs: https://docs.betterbase.io/cli +@betterbase/cli:test: +@betterbase/cli:test: 0.1.0 +@betterbase/cli:test: (pass) CLI argument parsing regression > parseAsync --version > throws CommanderError with code commander.version [1.00ms] +@betterbase/cli:test: (pass) CLI argument parsing regression > parseAsync unknown command > throws CommanderError for unrecognized subcommand [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: 24 tests failed: +@betterbase/cli:test: (fail) ContextGenerator > creates .betterbase-context.json from schema and routes [3.00ms] +@betterbase/cli:test: (fail) ContextGenerator > handles missing routes directory with empty routes +@betterbase/cli:test: (fail) ContextGenerator > handles empty schema file with empty tables [1.00ms] +@betterbase/cli:test: (fail) ContextGenerator > handles missing schema file with empty tables [2.00ms] +@betterbase/cli:test: (fail) Logger utility > info method > calls console.log with info symbol prefix [1.00ms] +@betterbase/cli:test: (fail) Logger utility > warn method > calls console.warn with warning symbol prefix +@betterbase/cli:test: (fail) Logger utility > error method > calls console.error with error symbol prefix and colored message +@betterbase/cli:test: (fail) Logger utility > error method > error shows hint when provided, with dim styling +@betterbase/cli:test: (fail) Logger utility > success method > calls console.log with success symbol prefix [3.00ms] +@betterbase/cli:test: (fail) Logger utility > keyValue method > prints indented key-value pair with padded key and cyan value +@betterbase/cli:test: (fail) Logger utility > keyValue method > value is colored cyan +@betterbase/cli:test: (fail) Logger utility > badge method > contains ANSI color codes (not plain text) +@betterbase/cli:test: (fail) generateWebhookId via runWebhookCreateCommand > creates a webhook ID with correct prefix and logs it [1.00ms] +@betterbase/cli:test: (fail) generateWebhookId via runWebhookCreateCommand > produces unique IDs across calls [1.00ms] +@betterbase/cli:test: (fail) generateWebhookId via runWebhookCreateCommand > IDs are monotonically increasing with time [1.00ms] +@betterbase/cli:test: (fail) runWebhookCreateCommand helpers > returns early when config file does not exist +@betterbase/cli:test: (fail) runInitCommand (IaC integration) > copies full IaC template into new project directory [6.00ms] +@betterbase/cli:test: (fail) Cross-Product Integration Pipeline (real implementations) > migrate generates migrations and GraphQL schema, then context builds on them [611.00ms] +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > iac sync generates schema.json and drizzle migrations +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > iac generate creates api.d.ts [1.00ms] +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > iac analyze scans queries and outputs analysis +@betterbase/cli:test: (fail) IAC Workflow Pipeline (real implementations) > full pipeline: sync → generate → analyze [1.00ms] +@betterbase/cli:test: +@betterbase/cli:test: 622 pass +@betterbase/cli:test: 24 fail +@betterbase/cli:test: 2 errors +@betterbase/cli:test: 1955 expect() calls +@betterbase/cli:test: Ran 646 tests across 37 files. [8.84s] +@betterbase/cli:test: error: script "test" exited with code 1 +@betterbase/cli:test: ERROR: command finished with error: command (/workspaces/Betterbase/packages/cli) /home/vscode/.bun/bin/bun run test exited (1) +@betterbase/cli#test: command (/workspaces/Betterbase/packages/cli) /home/vscode/.bun/bin/bun run test exited (1) + + Tasks: 7 successful, 8 total +Cached: 2 cached, 8 total + Time: 9.098s +Failed: @betterbase/cli#test + + ERROR run failed: command exited (1) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 TEST SUMMARY +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +✅ Passed: 1933 +❌ Failed: 24 +⏭️ Skipped: 1 +📝 Total Tests: 1958 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +$ turbo run lint +• turbo 2.8.12 +• Packages in scope: @betterbase/cli, @betterbase/client, @betterbase/core, @betterbase/server, @betterbase/shared, betterbase-base-template, betterbase-dashboard, my-betterbase-project +• Running lint in 8 packages +• Remote caching disabled +@betterbase/client:lint: cache hit, replaying logs f82656b8f8b1a42e +@betterbase/client:lint: +@betterbase/client:lint: $ bunx biome check src test +@betterbase/client:lint: Checked 23 files in 119ms. No fixes applied. +@betterbase/server:lint: cache hit, replaying logs 92674d4abbe4a6c0 +@betterbase/server:lint: +@betterbase/server:lint: $ echo 'No lint configured for server package' +@betterbase/server:lint: No lint configured for server package + + Tasks: 2 successful, 2 total +Cached: 2 cached, 2 total + Time: 111ms >>> FULL TURBO + +$ turbo run typecheck --filter "*" +• turbo 2.8.12 +• Packages in scope: //, @betterbase/cli, @betterbase/client, @betterbase/core, @betterbase/server, @betterbase/shared, betterbase-base-template, betterbase-dashboard, my-betterbase-project +• Running typecheck in 9 packages +• Remote caching disabled +@betterbase/cli:typecheck: cache miss, executing db4676f132b5f1d7 +@betterbase/client:typecheck: cache hit, replaying logs f5d614c74f1b6c18 +@betterbase/client:typecheck: $ tsc --noEmit --project tsconfig.json +betterbase-base-template:typecheck: cache hit, replaying logs 2441234296be7a4c +betterbase-base-template:typecheck: +betterbase-base-template:typecheck: $ tsc --noEmit +@betterbase/shared:typecheck: cache hit, replaying logs 5e144cc0a48bf02e +@betterbase/shared:typecheck: $ tsc --noEmit +@betterbase/core:typecheck: cache hit, replaying logs 5939016c938d170d +@betterbase/core:typecheck: $ tsc --noEmit +@betterbase/server:typecheck: cache hit, replaying logs 74eaa943e7781cf5 +@betterbase/server:typecheck: +@betterbase/server:typecheck: $ tsc --noEmit +@betterbase/cli:typecheck: $ tsc -p tsconfig.json --noEmit +@betterbase/cli:typecheck: test/cli/cli-parsing.test.ts(639,12): error TS2339: Property 'fail' does not exist on type 'Expect'. +@betterbase/cli:typecheck: test/cli/cli-parsing.test.ts(652,12): error TS2339: Property 'fail' does not exist on type 'Expect'. +@betterbase/cli:typecheck: test/cli/cli-parsing.test.ts(665,12): error TS2339: Property 'fail' does not exist on type 'Expect'. +@betterbase/cli:typecheck: test/dev.test.ts(162,38): error TS2769: No overload matches this call. +@betterbase/cli:typecheck: Overload 1 of 2, '(eventName: "SIGINT", listener: (signal: "SIGINT") => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(signal: "SIGINT") => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(signal: "SIGINT"): void'. +@betterbase/cli:typecheck: Overload 2 of 2, '(eventName: string | symbol, listener: (...args: any[]) => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(...args: any[]) => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(...args: any[]): void'. +@betterbase/cli:typecheck: test/dev.test.ts(167,39): error TS2769: No overload matches this call. +@betterbase/cli:typecheck: Overload 1 of 2, '(eventName: "SIGTERM", listener: (signal: "SIGTERM") => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(signal: "SIGTERM") => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(signal: "SIGTERM"): void'. +@betterbase/cli:typecheck: Overload 2 of 2, '(eventName: string | symbol, listener: (...args: any[]) => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(...args: any[]) => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(...args: any[]): void'. +@betterbase/cli:typecheck: test/integration/dev.test.ts(155,38): error TS2769: No overload matches this call. +@betterbase/cli:typecheck: Overload 1 of 2, '(eventName: "SIGINT", listener: (signal: "SIGINT") => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(signal: "SIGINT") => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(signal: "SIGINT"): void'. +@betterbase/cli:typecheck: Overload 2 of 2, '(eventName: string | symbol, listener: (...args: any[]) => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(...args: any[]) => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(...args: any[]): void'. +@betterbase/cli:typecheck: test/integration/dev.test.ts(160,39): error TS2769: No overload matches this call. +@betterbase/cli:typecheck: Overload 1 of 2, '(eventName: "SIGTERM", listener: (signal: "SIGTERM") => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(signal: "SIGTERM") => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(signal: "SIGTERM"): void'. +@betterbase/cli:typecheck: Overload 2 of 2, '(eventName: string | symbol, listener: (...args: any[]) => void): Process', gave the following error. +@betterbase/cli:typecheck: Argument of type 'Function' is not assignable to parameter of type '(...args: any[]) => void'. +@betterbase/cli:typecheck: Type 'Function' provides no match for the signature '(...args: any[]): void'. +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(4,10): error TS2305: Module '"node:os"' has no exported member 'mkdtempSync'. +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(207,19): error TS2552: Cannot find name 'setupCredentialsFile'. Did you mean 'cleanupCredentialsFile'? +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(207,40): error TS2304: Cannot find name 'createValidCredentials'. +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(225,19): error TS2552: Cannot find name 'setupCredentialsFile'. Did you mean 'cleanupCredentialsFile'? +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(225,40): error TS2304: Cannot find name 'createValidCredentials'. +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(251,19): error TS2552: Cannot find name 'setupCredentialsFile'. Did you mean 'cleanupCredentialsFile'? +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(251,40): error TS2304: Cannot find name 'createValidCredentials'. +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(269,19): error TS2552: Cannot find name 'setupCredentialsFile'. Did you mean 'cleanupCredentialsFile'? +@betterbase/cli:typecheck: test/integration/login-commands.test.ts(269,40): error TS2304: Cannot find name 'createExpiredCredentials'. +@betterbase/cli:typecheck: test/integration/webhook-commands.test.ts(822,5): error TS2304: Cannot find name 'captured'. +@betterbase/cli:typecheck: test/integration/webhook-commands.test.ts(829,7): error TS2304: Cannot find name 'captured'. +@betterbase/cli:typecheck: test/integration/webhook-commands.test.ts(832,20): error TS2304: Cannot find name 'captured'. +@betterbase/cli:typecheck: test/unit/api-client.test.ts(90,7): error TS2741: Property 'preconnect' is missing in type 'Mock<(input: URL | RequestInfo, init?: RequestInit | undefined) => Promise>' but required in type 'typeof fetch'. +@betterbase/cli:typecheck: test/unit/api-client.test.ts(117,7): error TS2741: Property 'preconnect' is missing in type 'Mock<() => Promise>' but required in type 'typeof fetch'. +@betterbase/cli:typecheck: test/unit/api-client.test.ts(141,7): error TS2741: Property 'preconnect' is missing in type 'Mock<() => Promise>' but required in type 'typeof fetch'. +@betterbase/cli:typecheck: test/unit/credentials.test.ts(3,10): error TS2305: Module '"node:os"' has no exported member 'mkdtempSync'. +@betterbase/cli:typecheck: ERROR: command finished with error: command (/workspaces/Betterbase/packages/cli) /home/vscode/.bun/bin/bun run typecheck exited (2) +@betterbase/cli#typecheck: command (/workspaces/Betterbase/packages/cli) /home/vscode/.bun/bin/bun run typecheck exited (2) + + Tasks: 5 successful, 6 total +Cached: 5 cached, 6 total + Time: 13.474s +Failed: @betterbase/cli#typecheck + + ERROR run failed: command exited (2)