@libredb/studio 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (572) hide show
  1. package/.claude/settings.local.json +127 -0
  2. package/.cursorrules +426 -0
  3. package/.devin/wiki.json +143 -0
  4. package/.dockerignore +80 -0
  5. package/.env.example +159 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  8. package/.github/PULL_REQUEST_TEMPLATE.md +57 -0
  9. package/.github/workflows/ci.yml +185 -0
  10. package/.github/workflows/codeql.yml +57 -0
  11. package/.github/workflows/docker-build-push.yml +118 -0
  12. package/.github/workflows/helm-release.yml +113 -0
  13. package/CLAUDE.md +265 -0
  14. package/CODE_OF_CONDUCT.md +124 -0
  15. package/CONTRIBUTING.md +154 -0
  16. package/Dockerfile +73 -0
  17. package/LICENSE +21 -0
  18. package/README.md +614 -0
  19. package/SECURITY.md +107 -0
  20. package/artifacthub-repo.yml +4 -0
  21. package/bun.lock +1714 -0
  22. package/bunfig.toml +3 -0
  23. package/charts/libredb-studio/.helmignore +11 -0
  24. package/charts/libredb-studio/Chart.lock +6 -0
  25. package/charts/libredb-studio/Chart.yaml +50 -0
  26. package/charts/libredb-studio/README.md +206 -0
  27. package/charts/libredb-studio/templates/NOTES.txt +59 -0
  28. package/charts/libredb-studio/templates/_helpers.tpl +135 -0
  29. package/charts/libredb-studio/templates/configmap.yaml +37 -0
  30. package/charts/libredb-studio/templates/deployment.yaml +184 -0
  31. package/charts/libredb-studio/templates/hpa.yaml +32 -0
  32. package/charts/libredb-studio/templates/ingress.yaml +41 -0
  33. package/charts/libredb-studio/templates/networkpolicy.yaml +50 -0
  34. package/charts/libredb-studio/templates/pdb.yaml +18 -0
  35. package/charts/libredb-studio/templates/pvc.yaml +23 -0
  36. package/charts/libredb-studio/templates/secret.yaml +30 -0
  37. package/charts/libredb-studio/templates/seed-configmap.yaml +11 -0
  38. package/charts/libredb-studio/templates/service.yaml +22 -0
  39. package/charts/libredb-studio/templates/serviceaccount.yaml +13 -0
  40. package/charts/libredb-studio/values.schema.json +246 -0
  41. package/charts/libredb-studio/values.yaml +286 -0
  42. package/components.json +22 -0
  43. package/conductor/code_styleguides/typescript.md +43 -0
  44. package/conductor/product-guidelines.md +43 -0
  45. package/conductor/product.md +3 -0
  46. package/conductor/setup_state.json +1 -0
  47. package/conductor/tech-stack.md +39 -0
  48. package/conductor/tracks/enhance_postgres_monitoring_20251227/metadata.json +8 -0
  49. package/conductor/tracks/enhance_postgres_monitoring_20251227/plan.md +44 -0
  50. package/conductor/tracks/enhance_postgres_monitoring_20251227/spec.md +31 -0
  51. package/conductor/tracks.md +8 -0
  52. package/conductor/workflow.md +333 -0
  53. package/database-compose.yml +55 -0
  54. package/docker/postgres-init/01-extensions.sql +10 -0
  55. package/docker/postgres-init/02-sample-data.sql +585 -0
  56. package/docker/postgres.yml +68 -0
  57. package/docker-compose.yml +38 -0
  58. package/docs/AI_PLAN.md +74 -0
  59. package/docs/API_DOCS.md +875 -0
  60. package/docs/ARCHITECTURE.md +218 -0
  61. package/docs/DATABASE_PROVIDERS.md +358 -0
  62. package/docs/FEATURES.md +116 -0
  63. package/docs/HELM_CHART.md +252 -0
  64. package/docs/LOGIN_PAGE.md +178 -0
  65. package/docs/MONACO_EDITOR_PERFORMANCE.md +315 -0
  66. package/docs/OIDC_ARCH.md +681 -0
  67. package/docs/OIDC_SETUP.md +322 -0
  68. package/docs/POSTGRES_METRICS.md +516 -0
  69. package/docs/QUERY_OPTIMIZATION.md +370 -0
  70. package/docs/SEED_CONNECTIONS.md +468 -0
  71. package/docs/SQL_ALIAS_COMPLETION.md +190 -0
  72. package/docs/STORAGE_ARCHITECTURE.md +565 -0
  73. package/docs/STORAGE_QUICK_SETUP.md +419 -0
  74. package/docs/TECHNICAL_PLAN.md +36 -0
  75. package/docs/THEMING.md +345 -0
  76. package/docs/adding-a-new-database-provider.md +642 -0
  77. package/docs/backlogs/000-PLATFORM_DATA_SYNC_DATABASE.md +360 -0
  78. package/docs/backlogs/001-INLINE_DATA_EDITING.md +118 -0
  79. package/docs/backlogs/002-DATA_IMPORT.md +215 -0
  80. package/docs/backlogs/003-QUERY_TIME_MACHINE.md +183 -0
  81. package/docs/backlogs/004-AI_DATA_STORYTELLER.md +292 -0
  82. package/docs/backlogs/005-QUERY_PLAYGROUND.md +352 -0
  83. package/docs/backlogs/006-DATA_MASKING.md +418 -0
  84. package/docs/enterprise-features.md +718 -0
  85. package/docs/kubernetes-helm-chart-artifacthub-plan.md +803 -0
  86. package/docs/medium-koyeb-article-en.md +215 -0
  87. package/docs/plans/test-plans.md +445 -0
  88. package/docs/releases/RELEASE.V0.3.0.md +22 -0
  89. package/docs/releases/RELEASE.V0.4.0.md +154 -0
  90. package/docs/releases/RELEASE.V0.5.0.md +252 -0
  91. package/docs/releases/RELEASE_v0.5.6.md +145 -0
  92. package/docs/releases/RELEASE_v0.6.1.md +303 -0
  93. package/docs/releases/RELEASE_v0.6.7.md +292 -0
  94. package/docs/releases/RELEASE_v0.7.0.md +332 -0
  95. package/docs/releases/RELEASE_v0.8.0.md +521 -0
  96. package/docs/sampledb/titanic.sql +1379 -0
  97. package/docs/superpowers/plans/2026-03-25-seed-connections.md +1362 -0
  98. package/docs/superpowers/specs/2026-03-25-seed-connections-design.md +590 -0
  99. package/e2e/admin-dashboard.spec.ts +64 -0
  100. package/e2e/connection-management.spec.ts +58 -0
  101. package/e2e/export.spec.ts +34 -0
  102. package/e2e/login.spec.ts +85 -0
  103. package/e2e/query-execution.spec.ts +35 -0
  104. package/e2e/tab-management.spec.ts +64 -0
  105. package/eslint.config.mjs +28 -0
  106. package/fly.toml +43 -0
  107. package/next.config.ts +32 -0
  108. package/package.json +130 -0
  109. package/playwright.config.ts +34 -0
  110. package/postcss.config.mjs +7 -0
  111. package/public/favicon-32x32.png +0 -0
  112. package/public/favicon.ico +0 -0
  113. package/public/file.svg +1 -0
  114. package/public/globe.svg +1 -0
  115. package/public/logo.svg +32 -0
  116. package/public/next.svg +1 -0
  117. package/public/screenshots/code-generator.png +0 -0
  118. package/public/screenshots/connection-modal.png +0 -0
  119. package/public/screenshots/data-profiler.png +0 -0
  120. package/public/screenshots/erd-diagram.png +0 -0
  121. package/public/screenshots/hero-editor.png +0 -0
  122. package/public/screenshots/nl2sql.png +0 -0
  123. package/public/vercel.svg +1 -0
  124. package/public/window.svg +1 -0
  125. package/render.yaml +58 -0
  126. package/scripts/merge-lcov.mjs +239 -0
  127. package/sonar-project.properties +16 -0
  128. package/src/app/admin/error.tsx +46 -0
  129. package/src/app/admin/page.tsx +10 -0
  130. package/src/app/api/admin/audit/route.ts +52 -0
  131. package/src/app/api/admin/fleet-health/route.ts +81 -0
  132. package/src/app/api/ai/autopilot/route.ts +105 -0
  133. package/src/app/api/ai/chat/route.ts +132 -0
  134. package/src/app/api/ai/describe-schema/route.ts +52 -0
  135. package/src/app/api/ai/explain/route.ts +86 -0
  136. package/src/app/api/ai/impact/route.ts +97 -0
  137. package/src/app/api/ai/index-advisor/route.ts +98 -0
  138. package/src/app/api/ai/nl2sql/route.ts +87 -0
  139. package/src/app/api/ai/query-safety/route.ts +87 -0
  140. package/src/app/api/auth/login/route.ts +62 -0
  141. package/src/app/api/auth/logout/route.ts +25 -0
  142. package/src/app/api/auth/me/route.ts +10 -0
  143. package/src/app/api/auth/oidc/callback/route.ts +82 -0
  144. package/src/app/api/auth/oidc/login/route.ts +43 -0
  145. package/src/app/api/connections/managed/route.ts +35 -0
  146. package/src/app/api/db/cancel/route.ts +42 -0
  147. package/src/app/api/db/disconnect/route.ts +28 -0
  148. package/src/app/api/db/health/route.ts +49 -0
  149. package/src/app/api/db/maintenance/route.ts +72 -0
  150. package/src/app/api/db/monitoring/route.ts +62 -0
  151. package/src/app/api/db/multi-query/route.ts +116 -0
  152. package/src/app/api/db/pool-stats/route.ts +37 -0
  153. package/src/app/api/db/profile/route.ts +144 -0
  154. package/src/app/api/db/provider-meta/route.ts +49 -0
  155. package/src/app/api/db/query/route.ts +50 -0
  156. package/src/app/api/db/schema/route.ts +47 -0
  157. package/src/app/api/db/schema-snapshot/route.ts +42 -0
  158. package/src/app/api/db/test-connection/route.ts +55 -0
  159. package/src/app/api/db/transaction/route.ts +111 -0
  160. package/src/app/api/storage/[collection]/route.ts +67 -0
  161. package/src/app/api/storage/config/route.ts +17 -0
  162. package/src/app/api/storage/migrate/route.ts +45 -0
  163. package/src/app/api/storage/route.ts +32 -0
  164. package/src/app/error.tsx +49 -0
  165. package/src/app/global-error.tsx +55 -0
  166. package/src/app/globals.css +146 -0
  167. package/src/app/icon.svg +42 -0
  168. package/src/app/layout.tsx +34 -0
  169. package/src/app/login/login-form.tsx +301 -0
  170. package/src/app/login/page.tsx +11 -0
  171. package/src/app/monitoring/page.tsx +8 -0
  172. package/src/app/not-found.tsx +29 -0
  173. package/src/app/page.tsx +5 -0
  174. package/src/components/AIAutopilotPanel.tsx +238 -0
  175. package/src/components/CodeGenerator.tsx +271 -0
  176. package/src/components/CommandPalette.tsx +227 -0
  177. package/src/components/ConnectionModal.tsx +759 -0
  178. package/src/components/CreateTableModal.tsx +281 -0
  179. package/src/components/DataCharts.tsx +962 -0
  180. package/src/components/DataImportModal.tsx +582 -0
  181. package/src/components/DataProfiler.tsx +335 -0
  182. package/src/components/DatabaseDocs.tsx +251 -0
  183. package/src/components/MaskingSettings.tsx +414 -0
  184. package/src/components/MobileNav.tsx +50 -0
  185. package/src/components/NL2SQLPanel.tsx +281 -0
  186. package/src/components/PivotTable.tsx +257 -0
  187. package/src/components/QueryEditor.tsx +760 -0
  188. package/src/components/QueryHistory.tsx +344 -0
  189. package/src/components/QuerySafetyDialog.tsx +290 -0
  190. package/src/components/ResultsGrid.tsx +644 -0
  191. package/src/components/SaveQueryModal.tsx +104 -0
  192. package/src/components/SavedQueries.tsx +128 -0
  193. package/src/components/SchemaDiagram.tsx +473 -0
  194. package/src/components/SchemaDiff.tsx +473 -0
  195. package/src/components/SnapshotTimeline.tsx +116 -0
  196. package/src/components/Studio.tsx +639 -0
  197. package/src/components/TestDataGenerator.tsx +261 -0
  198. package/src/components/VisualExplain.tsx +820 -0
  199. package/src/components/admin/AdminDashboard.tsx +163 -0
  200. package/src/components/admin/tabs/AuditTab.tsx +531 -0
  201. package/src/components/admin/tabs/MonitoringEmbed.tsx +11 -0
  202. package/src/components/admin/tabs/OperationsTab.tsx +646 -0
  203. package/src/components/admin/tabs/OverviewTab.tsx +1328 -0
  204. package/src/components/admin/tabs/SecurityTab.tsx +284 -0
  205. package/src/components/community-section.tsx +92 -0
  206. package/src/components/icons/db-icons.tsx +84 -0
  207. package/src/components/libredb-logo.tsx +61 -0
  208. package/src/components/monitoring/MonitoringDashboard.tsx +345 -0
  209. package/src/components/monitoring/tabs/MetricChart.tsx +82 -0
  210. package/src/components/monitoring/tabs/OverviewTab.tsx +263 -0
  211. package/src/components/monitoring/tabs/PerformanceTab.tsx +254 -0
  212. package/src/components/monitoring/tabs/PoolTab.tsx +174 -0
  213. package/src/components/monitoring/tabs/QueriesTab.tsx +287 -0
  214. package/src/components/monitoring/tabs/SessionsTab.tsx +316 -0
  215. package/src/components/monitoring/tabs/StorageTab.tsx +335 -0
  216. package/src/components/monitoring/tabs/TablesTab.tsx +300 -0
  217. package/src/components/results-grid/ResultCard.tsx +111 -0
  218. package/src/components/results-grid/RowDetailSheet.tsx +178 -0
  219. package/src/components/results-grid/StatsBar.tsx +201 -0
  220. package/src/components/results-grid/index.ts +1 -0
  221. package/src/components/results-grid/utils.ts +23 -0
  222. package/src/components/schema-explorer/ColumnList.tsx +53 -0
  223. package/src/components/schema-explorer/SchemaExplorer.tsx +182 -0
  224. package/src/components/schema-explorer/TableItem.tsx +210 -0
  225. package/src/components/schema-explorer/index.ts +1 -0
  226. package/src/components/sidebar/ConnectionItem.tsx +105 -0
  227. package/src/components/sidebar/ConnectionsList.tsx +62 -0
  228. package/src/components/sidebar/Sidebar.tsx +130 -0
  229. package/src/components/sidebar/index.ts +2 -0
  230. package/src/components/studio/BottomPanel.tsx +286 -0
  231. package/src/components/studio/QueryToolbar.tsx +180 -0
  232. package/src/components/studio/StudioDesktopHeader.tsx +114 -0
  233. package/src/components/studio/StudioMobileHeader.tsx +340 -0
  234. package/src/components/studio/StudioTabBar.tsx +82 -0
  235. package/src/components/studio/index.ts +5 -0
  236. package/src/components/ui/accordion.tsx +66 -0
  237. package/src/components/ui/alert-dialog.tsx +157 -0
  238. package/src/components/ui/alert.tsx +66 -0
  239. package/src/components/ui/aspect-ratio.tsx +11 -0
  240. package/src/components/ui/avatar.tsx +53 -0
  241. package/src/components/ui/badge.tsx +46 -0
  242. package/src/components/ui/breadcrumb.tsx +109 -0
  243. package/src/components/ui/button-group.tsx +83 -0
  244. package/src/components/ui/button.tsx +60 -0
  245. package/src/components/ui/calendar.tsx +216 -0
  246. package/src/components/ui/card.tsx +92 -0
  247. package/src/components/ui/carousel.tsx +241 -0
  248. package/src/components/ui/chart.tsx +357 -0
  249. package/src/components/ui/checkbox.tsx +32 -0
  250. package/src/components/ui/collapsible.tsx +33 -0
  251. package/src/components/ui/command.tsx +184 -0
  252. package/src/components/ui/context-menu.tsx +252 -0
  253. package/src/components/ui/dialog.tsx +143 -0
  254. package/src/components/ui/drawer.tsx +135 -0
  255. package/src/components/ui/dropdown-menu.tsx +257 -0
  256. package/src/components/ui/empty.tsx +104 -0
  257. package/src/components/ui/field.tsx +248 -0
  258. package/src/components/ui/form.tsx +167 -0
  259. package/src/components/ui/hover-card.tsx +44 -0
  260. package/src/components/ui/input-group.tsx +170 -0
  261. package/src/components/ui/input-otp.tsx +77 -0
  262. package/src/components/ui/input.tsx +21 -0
  263. package/src/components/ui/item.tsx +193 -0
  264. package/src/components/ui/kbd.tsx +28 -0
  265. package/src/components/ui/label.tsx +24 -0
  266. package/src/components/ui/menubar.tsx +276 -0
  267. package/src/components/ui/navigation-menu.tsx +168 -0
  268. package/src/components/ui/pagination.tsx +127 -0
  269. package/src/components/ui/popover.tsx +48 -0
  270. package/src/components/ui/progress.tsx +31 -0
  271. package/src/components/ui/radio-group.tsx +45 -0
  272. package/src/components/ui/resizable.tsx +56 -0
  273. package/src/components/ui/scroll-area.tsx +58 -0
  274. package/src/components/ui/select.tsx +187 -0
  275. package/src/components/ui/separator.tsx +28 -0
  276. package/src/components/ui/sheet.tsx +139 -0
  277. package/src/components/ui/sidebar.tsx +726 -0
  278. package/src/components/ui/skeleton.tsx +13 -0
  279. package/src/components/ui/slider.tsx +63 -0
  280. package/src/components/ui/sonner.tsx +40 -0
  281. package/src/components/ui/spinner.tsx +16 -0
  282. package/src/components/ui/switch.tsx +31 -0
  283. package/src/components/ui/table.tsx +116 -0
  284. package/src/components/ui/tabs.tsx +66 -0
  285. package/src/components/ui/textarea.tsx +18 -0
  286. package/src/components/ui/toggle-group.tsx +83 -0
  287. package/src/components/ui/toggle.tsx +47 -0
  288. package/src/components/ui/tooltip.tsx +61 -0
  289. package/src/exports/components.ts +15 -0
  290. package/src/exports/index.ts +4 -0
  291. package/src/exports/providers.ts +4 -0
  292. package/src/exports/types.ts +26 -0
  293. package/src/hooks/use-ai-chat.ts +182 -0
  294. package/src/hooks/use-all-connections.ts +66 -0
  295. package/src/hooks/use-api-call.ts +71 -0
  296. package/src/hooks/use-auth.ts +51 -0
  297. package/src/hooks/use-connection-form.ts +349 -0
  298. package/src/hooks/use-connection-manager.ts +169 -0
  299. package/src/hooks/use-connection-payload.ts +15 -0
  300. package/src/hooks/use-inline-editing.ts +109 -0
  301. package/src/hooks/use-mobile.ts +20 -0
  302. package/src/hooks/use-monitoring-data.ts +270 -0
  303. package/src/hooks/use-provider-metadata.ts +62 -0
  304. package/src/hooks/use-query-execution.ts +478 -0
  305. package/src/hooks/use-storage-sync.ts +259 -0
  306. package/src/hooks/use-tab-manager.ts +231 -0
  307. package/src/hooks/use-toast.ts +20 -0
  308. package/src/hooks/use-transaction-control.ts +64 -0
  309. package/src/lib/api/error-codes.ts +30 -0
  310. package/src/lib/api/errors.ts +236 -0
  311. package/src/lib/api/with-error-handler.ts +41 -0
  312. package/src/lib/audit.ts +105 -0
  313. package/src/lib/auth.ts +87 -0
  314. package/src/lib/connection-string-parser.ts +172 -0
  315. package/src/lib/data-masking.ts +385 -0
  316. package/src/lib/db/base-provider.ts +325 -0
  317. package/src/lib/db/errors.ts +317 -0
  318. package/src/lib/db/factory.ts +324 -0
  319. package/src/lib/db/index.ts +123 -0
  320. package/src/lib/db/providers/document/index.ts +6 -0
  321. package/src/lib/db/providers/document/mongodb.ts +992 -0
  322. package/src/lib/db/providers/keyvalue/redis.ts +554 -0
  323. package/src/lib/db/providers/sql/index.ts +11 -0
  324. package/src/lib/db/providers/sql/mssql.ts +1065 -0
  325. package/src/lib/db/providers/sql/mysql.ts +978 -0
  326. package/src/lib/db/providers/sql/oracle.ts +1044 -0
  327. package/src/lib/db/providers/sql/postgres.ts +1179 -0
  328. package/src/lib/db/providers/sql/sql-base.ts +174 -0
  329. package/src/lib/db/providers/sql/sqlite.ts +721 -0
  330. package/src/lib/db/types.ts +437 -0
  331. package/src/lib/db/utils/pool-manager.ts +287 -0
  332. package/src/lib/db/utils/query-limiter.ts +239 -0
  333. package/src/lib/db-ui-config.ts +86 -0
  334. package/src/lib/editor/mongodb-completions.ts +172 -0
  335. package/src/lib/editor/sql-completions.ts +280 -0
  336. package/src/lib/llm/base-provider.ts +117 -0
  337. package/src/lib/llm/factory.ts +102 -0
  338. package/src/lib/llm/index.ts +90 -0
  339. package/src/lib/llm/providers/custom.ts +181 -0
  340. package/src/lib/llm/providers/gemini.ts +126 -0
  341. package/src/lib/llm/providers/ollama.ts +154 -0
  342. package/src/lib/llm/providers/openai.ts +146 -0
  343. package/src/lib/llm/types.ts +173 -0
  344. package/src/lib/llm/utils/config.ts +187 -0
  345. package/src/lib/llm/utils/retry.ts +119 -0
  346. package/src/lib/llm/utils/streaming.ts +202 -0
  347. package/src/lib/logger.ts +127 -0
  348. package/src/lib/monitoring-thresholds.ts +44 -0
  349. package/src/lib/oidc.ts +262 -0
  350. package/src/lib/query-generators.ts +61 -0
  351. package/src/lib/schema-diff/diff-engine.ts +273 -0
  352. package/src/lib/schema-diff/migration-generator.ts +208 -0
  353. package/src/lib/schema-diff/types.ts +55 -0
  354. package/src/lib/seed/config-loader.ts +79 -0
  355. package/src/lib/seed/connection-filter.ts +49 -0
  356. package/src/lib/seed/credential-resolver.ts +62 -0
  357. package/src/lib/seed/index.ts +40 -0
  358. package/src/lib/seed/resolve-connection.ts +57 -0
  359. package/src/lib/seed/types.ts +69 -0
  360. package/src/lib/sql/alias-extractor.ts +267 -0
  361. package/src/lib/sql/index.ts +8 -0
  362. package/src/lib/sql/statement-splitter.ts +167 -0
  363. package/src/lib/sql/types.ts +40 -0
  364. package/src/lib/ssh/tunnel.ts +142 -0
  365. package/src/lib/storage/factory.ts +84 -0
  366. package/src/lib/storage/index.ts +14 -0
  367. package/src/lib/storage/local-storage.ts +99 -0
  368. package/src/lib/storage/providers/postgres.ts +225 -0
  369. package/src/lib/storage/providers/sqlite.ts +153 -0
  370. package/src/lib/storage/storage-facade.ts +272 -0
  371. package/src/lib/storage/types.ts +75 -0
  372. package/src/lib/time-series-buffer.ts +58 -0
  373. package/src/lib/types.ts +173 -0
  374. package/src/lib/utils.ts +6 -0
  375. package/src/proxy.ts +104 -0
  376. package/src/types/db-drivers.d.ts +23 -0
  377. package/src/types/html2canvas.d.ts +9 -0
  378. package/tests/api/admin/audit.test.ts +178 -0
  379. package/tests/api/admin/fleet-health.test.ts +183 -0
  380. package/tests/api/ai/autopilot.test.ts +174 -0
  381. package/tests/api/ai/chat.test.ts +250 -0
  382. package/tests/api/ai/describe-schema.test.ts +266 -0
  383. package/tests/api/ai/explain.test.ts +199 -0
  384. package/tests/api/ai/impact.test.ts +168 -0
  385. package/tests/api/ai/index-advisor.test.ts +171 -0
  386. package/tests/api/ai/nl2sql.test.ts +202 -0
  387. package/tests/api/ai/query-safety.test.ts +196 -0
  388. package/tests/api/auth/login.test.ts +170 -0
  389. package/tests/api/auth/logout.test.ts +140 -0
  390. package/tests/api/auth/me.test.ts +73 -0
  391. package/tests/api/auth/oidc-callback.test.ts +215 -0
  392. package/tests/api/auth/oidc-login.test.ts +127 -0
  393. package/tests/api/db/cancel.test.ts +198 -0
  394. package/tests/api/db/disconnect.test.ts +124 -0
  395. package/tests/api/db/health.test.ts +222 -0
  396. package/tests/api/db/maintenance.test.ts +263 -0
  397. package/tests/api/db/monitoring.test.ts +221 -0
  398. package/tests/api/db/multi-query.test.ts +316 -0
  399. package/tests/api/db/pool-stats.test.ts +135 -0
  400. package/tests/api/db/profile.test.ts +330 -0
  401. package/tests/api/db/provider-meta.test.ts +193 -0
  402. package/tests/api/db/query.test.ts +314 -0
  403. package/tests/api/db/schema-snapshot.test.ts +170 -0
  404. package/tests/api/db/schema.test.ts +191 -0
  405. package/tests/api/db/test-connection.test.ts +185 -0
  406. package/tests/api/db/transaction.test.ts +314 -0
  407. package/tests/api/proxy.test.ts +191 -0
  408. package/tests/api/seed/managed-route.test.ts +113 -0
  409. package/tests/api/storage/config.test.ts +42 -0
  410. package/tests/api/storage/storage-routes.test.ts +309 -0
  411. package/tests/components/AIAutopilotPanel.test.tsx +756 -0
  412. package/tests/components/AdminPage.test.tsx +33 -0
  413. package/tests/components/CodeGenerator.test.tsx +182 -0
  414. package/tests/components/CommandPalette.test.tsx +428 -0
  415. package/tests/components/CommunitySection.test.tsx +91 -0
  416. package/tests/components/ConnectionModal.mobile.test.tsx +284 -0
  417. package/tests/components/ConnectionModal.test.tsx +570 -0
  418. package/tests/components/CreateTableModal.test.tsx +383 -0
  419. package/tests/components/DataCharts.test.tsx +739 -0
  420. package/tests/components/DataImportModal.test.tsx +751 -0
  421. package/tests/components/DataProfiler.test.tsx +589 -0
  422. package/tests/components/DatabaseDocs.test.tsx +353 -0
  423. package/tests/components/LoginPage.test.tsx +163 -0
  424. package/tests/components/LoginPageOIDC.test.tsx +92 -0
  425. package/tests/components/MaskingSettings.test.tsx +498 -0
  426. package/tests/components/MobileNav.test.tsx +30 -0
  427. package/tests/components/MonitoringPage.test.tsx +32 -0
  428. package/tests/components/NL2SQLPanel.test.tsx +621 -0
  429. package/tests/components/Page.test.tsx +33 -0
  430. package/tests/components/PivotTable.test.tsx +350 -0
  431. package/tests/components/QueryEditor.test.tsx +1730 -0
  432. package/tests/components/QueryHistory.test.tsx +572 -0
  433. package/tests/components/QuerySafetyDialog.test.tsx +586 -0
  434. package/tests/components/ResultsGrid.test.tsx +804 -0
  435. package/tests/components/RootLayout.test.tsx +83 -0
  436. package/tests/components/SaveQueryModal.test.tsx +25 -0
  437. package/tests/components/SavedQueries.test.tsx +43 -0
  438. package/tests/components/SchemaDiagram.test.tsx +1034 -0
  439. package/tests/components/SchemaDiff.test.tsx +906 -0
  440. package/tests/components/SnapshotTimeline.test.tsx +174 -0
  441. package/tests/components/Studio.test.tsx +1030 -0
  442. package/tests/components/TestDataGenerator.test.tsx +291 -0
  443. package/tests/components/VisualExplain.test.tsx +704 -0
  444. package/tests/components/admin/AdminDashboard.test.tsx +205 -0
  445. package/tests/components/admin/AuditTab.test.tsx +220 -0
  446. package/tests/components/admin/MonitoringEmbed.test.tsx +58 -0
  447. package/tests/components/admin/OperationsTab.test.tsx +975 -0
  448. package/tests/components/admin/OverviewTab.test.tsx +254 -0
  449. package/tests/components/admin/SecurityTab.test.tsx +467 -0
  450. package/tests/components/monitoring/MetricChart.test.tsx +111 -0
  451. package/tests/components/monitoring/MonitoringDashboard.test.tsx +259 -0
  452. package/tests/components/monitoring/OverviewTab.test.tsx +78 -0
  453. package/tests/components/monitoring/PerformanceTab.test.tsx +87 -0
  454. package/tests/components/monitoring/PoolTab.test.tsx +42 -0
  455. package/tests/components/monitoring/QueriesTab.test.tsx +80 -0
  456. package/tests/components/monitoring/SessionsTab.test.tsx +154 -0
  457. package/tests/components/monitoring/StorageTab.test.tsx +127 -0
  458. package/tests/components/monitoring/TablesTab.test.tsx +153 -0
  459. package/tests/components/results-grid/ResultCard.test.tsx +105 -0
  460. package/tests/components/results-grid/RowDetailSheet.test.tsx +308 -0
  461. package/tests/components/results-grid/StatsBar.test.tsx +162 -0
  462. package/tests/components/schema-explorer/ColumnList.test.tsx +151 -0
  463. package/tests/components/schema-explorer/SchemaExplorer.test.tsx +461 -0
  464. package/tests/components/schema-explorer/TableItem.test.tsx +415 -0
  465. package/tests/components/sidebar/ConnectionItem.test.tsx +201 -0
  466. package/tests/components/sidebar/ConnectionsList.test.tsx +176 -0
  467. package/tests/components/sidebar/Sidebar.test.tsx +187 -0
  468. package/tests/components/studio/BottomPanel.test.tsx +383 -0
  469. package/tests/components/studio/QueryToolbar.test.tsx +321 -0
  470. package/tests/components/studio/StudioDesktopHeader.test.tsx +377 -0
  471. package/tests/components/studio/StudioMobileHeader.test.tsx +198 -0
  472. package/tests/components/studio/StudioTabBar.test.tsx +331 -0
  473. package/tests/fixtures/connections.ts +96 -0
  474. package/tests/fixtures/masking-configs.ts +86 -0
  475. package/tests/fixtures/query-results.ts +71 -0
  476. package/tests/fixtures/schemas.ts +64 -0
  477. package/tests/fixtures/seed-connections/invalid-config.yaml +7 -0
  478. package/tests/fixtures/seed-connections/minimal-config.yaml +8 -0
  479. package/tests/fixtures/seed-connections/mixed-credentials.yaml +23 -0
  480. package/tests/fixtures/seed-connections/multi-role-config.yaml +30 -0
  481. package/tests/fixtures/seed-connections/valid-config.json +15 -0
  482. package/tests/fixtures/seed-connections/valid-config.yaml +51 -0
  483. package/tests/helpers/mock-fetch.ts +59 -0
  484. package/tests/helpers/mock-monaco.ts +112 -0
  485. package/tests/helpers/mock-navigation.ts +28 -0
  486. package/tests/helpers/mock-next.ts +80 -0
  487. package/tests/helpers/mock-provider.ts +133 -0
  488. package/tests/helpers/mock-sonner.ts +29 -0
  489. package/tests/helpers/render-with-providers.tsx +19 -0
  490. package/tests/hooks/use-ai-chat.test.ts +600 -0
  491. package/tests/hooks/use-auth.test.ts +371 -0
  492. package/tests/hooks/use-connection-form.test.ts +743 -0
  493. package/tests/hooks/use-connection-manager.test.ts +466 -0
  494. package/tests/hooks/use-inline-editing.test.ts +321 -0
  495. package/tests/hooks/use-mobile.test.ts +177 -0
  496. package/tests/hooks/use-monitoring-data.test.ts +819 -0
  497. package/tests/hooks/use-provider-metadata.test.ts +228 -0
  498. package/tests/hooks/use-query-execution.test.ts +1212 -0
  499. package/tests/hooks/use-tab-manager.test.ts +756 -0
  500. package/tests/hooks/use-toast.test.ts +74 -0
  501. package/tests/hooks/use-transaction-control.test.ts +211 -0
  502. package/tests/integration/db/mongodb-provider.test.ts +698 -0
  503. package/tests/integration/db/mssql-provider.test.ts +840 -0
  504. package/tests/integration/db/mysql-provider.test.ts +872 -0
  505. package/tests/integration/db/oracle-provider.test.ts +843 -0
  506. package/tests/integration/db/postgres-provider.test.ts +1382 -0
  507. package/tests/integration/db/redis-provider.test.ts +526 -0
  508. package/tests/integration/db/sqlite-provider.test.ts +480 -0
  509. package/tests/integration/seed/seed-pipeline.test.ts +102 -0
  510. package/tests/isolated/factory-singleton.test.ts +150 -0
  511. package/tests/isolated/use-storage-sync.test.ts +389 -0
  512. package/tests/run-components.sh +196 -0
  513. package/tests/setup-dom.ts +58 -0
  514. package/tests/setup.ts +40 -0
  515. package/tests/unit/api-errors.test.ts +210 -0
  516. package/tests/unit/code-generator-functions.test.ts +271 -0
  517. package/tests/unit/components/column-list.test.tsx +190 -0
  518. package/tests/unit/components/data-import-modal.test.tsx +441 -0
  519. package/tests/unit/components/studio-mobile-header.test.tsx +327 -0
  520. package/tests/unit/data-charts-functions.test.ts +496 -0
  521. package/tests/unit/data-import-functions.test.ts +320 -0
  522. package/tests/unit/data-import-utils.test.ts +125 -0
  523. package/tests/unit/db/base-provider.test.ts +517 -0
  524. package/tests/unit/db/errors.test.ts +403 -0
  525. package/tests/unit/db/factory.test.ts +436 -0
  526. package/tests/unit/db/pool-manager.test.ts +440 -0
  527. package/tests/unit/db/query-limiter.test.ts +387 -0
  528. package/tests/unit/db/sql-base.test.ts +438 -0
  529. package/tests/unit/lib/api/error-codes.test.ts +39 -0
  530. package/tests/unit/lib/audit.test.ts +326 -0
  531. package/tests/unit/lib/auth.test.ts +146 -0
  532. package/tests/unit/lib/connection-string-parser.test.ts +424 -0
  533. package/tests/unit/lib/data-masking.test.ts +583 -0
  534. package/tests/unit/lib/db-icons.test.tsx +41 -0
  535. package/tests/unit/lib/monitoring-thresholds.test.ts +133 -0
  536. package/tests/unit/lib/oidc.test.ts +509 -0
  537. package/tests/unit/lib/query-generators.test.ts +127 -0
  538. package/tests/unit/lib/storage/factory.test.ts +71 -0
  539. package/tests/unit/lib/storage/local-storage.test.ts +114 -0
  540. package/tests/unit/lib/storage/providers/postgres.test.ts +312 -0
  541. package/tests/unit/lib/storage/providers/sqlite.test.ts +232 -0
  542. package/tests/unit/lib/storage/storage-facade-extended.test.ts +331 -0
  543. package/tests/unit/lib/storage/storage-facade.test.ts +184 -0
  544. package/tests/unit/lib/storage.test.ts +317 -0
  545. package/tests/unit/lib/time-series-buffer.test.ts +212 -0
  546. package/tests/unit/lib/utils.test.ts +24 -0
  547. package/tests/unit/llm/base-provider.test.ts +238 -0
  548. package/tests/unit/llm/config.test.ts +262 -0
  549. package/tests/unit/llm/custom-provider.test.ts +281 -0
  550. package/tests/unit/llm/gemini-provider.test.ts +248 -0
  551. package/tests/unit/llm/llm-factory.test.ts +155 -0
  552. package/tests/unit/llm/ollama-provider.test.ts +288 -0
  553. package/tests/unit/llm/openai-provider.test.ts +324 -0
  554. package/tests/unit/llm/retry.test.ts +180 -0
  555. package/tests/unit/llm/streaming.test.ts +355 -0
  556. package/tests/unit/logger.test.ts +198 -0
  557. package/tests/unit/mongodb-completions.test.ts +516 -0
  558. package/tests/unit/pivot-table-functions.test.ts +76 -0
  559. package/tests/unit/query-cancelled-error.test.ts +81 -0
  560. package/tests/unit/schema-diff/diff-engine.test.ts +367 -0
  561. package/tests/unit/schema-diff/migration-generator.test.ts +513 -0
  562. package/tests/unit/seed/config-loader.test.ts +73 -0
  563. package/tests/unit/seed/connection-filter.test.ts +91 -0
  564. package/tests/unit/seed/credential-resolver.test.ts +85 -0
  565. package/tests/unit/seed/index.test.ts +72 -0
  566. package/tests/unit/seed/resolve-connection.test.ts +74 -0
  567. package/tests/unit/seed/types.test.ts +129 -0
  568. package/tests/unit/sql/alias-extractor.test.ts +444 -0
  569. package/tests/unit/sql/statement-splitter.test.ts +348 -0
  570. package/tests/unit/sql-completions.test.ts +463 -0
  571. package/tests/unit/ssh-tunnel.test.ts +465 -0
  572. package/tsconfig.json +42 -0
@@ -0,0 +1,1034 @@
1
+ import '../setup-dom';
2
+ import '../helpers/mock-sonner';
3
+ import '../helpers/mock-navigation';
4
+
5
+ import { mock } from 'bun:test';
6
+ import { setupFramerMotionMock } from '../helpers/mock-monaco';
7
+
8
+ // Enhanced XYFlow mock that renders nodes via nodeTypes
9
+ mock.module('@xyflow/react', () => {
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ const React = require('react');
12
+ return {
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ ReactFlow: ({ children, nodes = [], nodeTypes = {}, onNodeClick, onPaneClick }: Record<string, any>) => {
15
+ const renderedNodes = nodes.map((node: { id: string; type: string; data: Record<string, unknown> }) => {
16
+ const NodeComp = nodeTypes[node.type];
17
+ if (!NodeComp) return null;
18
+ return React.createElement('div', {
19
+ key: node.id,
20
+ 'data-testid': `node-${node.id}`,
21
+ 'data-node-id': node.id,
22
+ onClick: (e: React.MouseEvent) => { e.stopPropagation(); onNodeClick?.(e, node); },
23
+ }, React.createElement(NodeComp, { id: node.id, data: node.data, type: node.type }));
24
+ });
25
+ // Wrap nodes in a keyed container to avoid reconciliation issues
26
+ // when the number of nodes changes (e.g. during search filtering)
27
+ return React.createElement('div', {
28
+ 'data-testid': 'mock-react-flow',
29
+ className: 'react-flow',
30
+ onClick: (e: React.MouseEvent) => { if (e.target === e.currentTarget) onPaneClick?.(); },
31
+ },
32
+ React.createElement('div', { key: '__nodes__', 'data-testid': 'nodes-container' }, renderedNodes),
33
+ React.createElement('svg', { key: '__svg__' }),
34
+ React.createElement(React.Fragment, { key: '__children__' }, children),
35
+ );
36
+ },
37
+ ReactFlowProvider: ({ children }: { children: unknown }) => children,
38
+ MiniMap: () => React.createElement('div', { 'data-testid': 'mock-minimap' }),
39
+ Controls: () => null,
40
+ Background: () => null,
41
+ Handle: () => null,
42
+ useNodesState: () => [[], mock(() => {}), mock(() => {})],
43
+ useEdgesState: () => [[], mock(() => {}), mock(() => {})],
44
+ useReactFlow: () => ({ fitView: mock(() => {}), getNodes: mock(() => []), getEdges: mock(() => []) }),
45
+ Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
46
+ MarkerType: { ArrowClosed: 'arrowclosed' },
47
+ Panel: ({ children, position }: { children: unknown; position?: string }) =>
48
+ React.createElement('div', { 'data-testid': `mock-panel-${position || 'default'}` }, children),
49
+ };
50
+ });
51
+
52
+ setupFramerMotionMock();
53
+
54
+ // Mock elkjs
55
+ mock.module('elkjs/lib/elk.bundled.js', () => ({
56
+ default: class MockELK {
57
+ layout(graph: unknown) {
58
+ return Promise.resolve(graph);
59
+ }
60
+ },
61
+ }));
62
+
63
+ // Track html2canvas calls
64
+ const mockHtml2canvas = mock(() => Promise.resolve({
65
+ toDataURL: () => 'data:image/png;base64,mock',
66
+ }));
67
+
68
+ mock.module('html2canvas', () => ({
69
+ default: mockHtml2canvas,
70
+ }));
71
+
72
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
73
+ import { render, fireEvent, within, cleanup, act } from '@testing-library/react';
74
+ import React from 'react';
75
+
76
+ import { SchemaDiagram } from '@/components/SchemaDiagram';
77
+ import { mockSchema, emptySchema } from '../fixtures/schemas';
78
+ import type { TableSchema } from '@/lib/types';
79
+
80
+ // =============================================================================
81
+ // Test Data
82
+ // =============================================================================
83
+
84
+ // Schema with NO foreign keys at all (triggers heuristic fallback)
85
+ const schemaNoFK: TableSchema[] = [
86
+ {
87
+ name: 'users',
88
+ columns: [
89
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
90
+ { name: 'name', type: 'varchar(255)', nullable: false, isPrimary: false },
91
+ ],
92
+ indexes: [],
93
+ foreignKeys: [],
94
+ rowCount: 100,
95
+ },
96
+ {
97
+ name: 'posts',
98
+ columns: [
99
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
100
+ { name: 'title', type: 'text', nullable: false, isPrimary: false },
101
+ ],
102
+ indexes: [],
103
+ foreignKeys: [],
104
+ rowCount: 50,
105
+ },
106
+ ];
107
+
108
+ // Schema with heuristic _id column (no FK data, but column ends with _id)
109
+ const schemaHeuristic: TableSchema[] = [
110
+ {
111
+ name: 'users',
112
+ columns: [
113
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
114
+ { name: 'email', type: 'varchar', nullable: true, isPrimary: false },
115
+ ],
116
+ indexes: [],
117
+ foreignKeys: [],
118
+ rowCount: 10,
119
+ },
120
+ {
121
+ name: 'comments',
122
+ columns: [
123
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
124
+ { name: 'user_id', type: 'integer', nullable: false, isPrimary: false },
125
+ { name: 'body', type: 'text', nullable: false, isPrimary: false },
126
+ ],
127
+ indexes: [],
128
+ foreignKeys: [],
129
+ rowCount: 200,
130
+ },
131
+ ];
132
+
133
+ // Schema with heuristic _id column matching singular table name (no plural 's')
134
+ const schemaHeuristicSingular: TableSchema[] = [
135
+ {
136
+ name: 'author',
137
+ columns: [
138
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
139
+ { name: 'name', type: 'varchar(255)', nullable: false, isPrimary: false },
140
+ ],
141
+ indexes: [],
142
+ foreignKeys: [],
143
+ rowCount: 10,
144
+ },
145
+ {
146
+ name: 'books',
147
+ columns: [
148
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
149
+ { name: 'author_id', type: 'integer', nullable: false, isPrimary: false },
150
+ { name: 'title', type: 'text', nullable: false, isPrimary: false },
151
+ ],
152
+ indexes: [],
153
+ foreignKeys: [],
154
+ rowCount: 50,
155
+ },
156
+ ];
157
+
158
+ // Schema with foreignKeys field omitted (tests `|| []` guards)
159
+ const schemaUndefinedFK: TableSchema[] = [
160
+ {
161
+ name: 'items',
162
+ columns: [
163
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
164
+ { name: 'label', type: 'text', nullable: true, isPrimary: false },
165
+ ],
166
+ indexes: [],
167
+ rowCount: 20,
168
+ } as TableSchema,
169
+ ];
170
+
171
+ // Multi-FK schema for highlighting tests
172
+ const schemaMultiFK: TableSchema[] = [
173
+ {
174
+ name: 'users',
175
+ columns: [
176
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
177
+ { name: 'name', type: 'varchar(255)', nullable: false, isPrimary: false },
178
+ ],
179
+ indexes: [],
180
+ foreignKeys: [],
181
+ rowCount: 100,
182
+ },
183
+ {
184
+ name: 'orders',
185
+ columns: [
186
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
187
+ { name: 'user_id', type: 'integer', nullable: false, isPrimary: false },
188
+ { name: 'total', type: 'numeric(10,2)', nullable: false, isPrimary: false },
189
+ ],
190
+ indexes: [],
191
+ foreignKeys: [
192
+ { columnName: 'user_id', referencedTable: 'users', referencedColumn: 'id' },
193
+ ],
194
+ rowCount: 500,
195
+ },
196
+ {
197
+ name: 'items',
198
+ columns: [
199
+ { name: 'id', type: 'integer', nullable: false, isPrimary: true },
200
+ { name: 'order_id', type: 'integer', nullable: false, isPrimary: false },
201
+ { name: 'product', type: 'varchar(255)', nullable: false, isPrimary: false },
202
+ ],
203
+ indexes: [],
204
+ foreignKeys: [
205
+ { columnName: 'order_id', referencedTable: 'orders', referencedColumn: 'id' },
206
+ ],
207
+ rowCount: 1000,
208
+ },
209
+ ];
210
+
211
+ // Single table schema
212
+ const singleTableSchema: TableSchema[] = [
213
+ {
214
+ name: 'settings',
215
+ columns: [
216
+ { name: 'key', type: 'text', nullable: false, isPrimary: true },
217
+ { name: 'value', type: 'text', nullable: true, isPrimary: false },
218
+ ],
219
+ indexes: [],
220
+ foreignKeys: [],
221
+ rowCount: 5,
222
+ },
223
+ ];
224
+
225
+ // =============================================================================
226
+ // Helpers
227
+ // =============================================================================
228
+
229
+ function createDefaultProps(overrides: Partial<Parameters<typeof SchemaDiagram>[0]> = {}) {
230
+ return {
231
+ schema: mockSchema,
232
+ onClose: mock(() => {}),
233
+ ...overrides,
234
+ };
235
+ }
236
+
237
+ // =============================================================================
238
+ // SchemaDiagram Tests
239
+ // =============================================================================
240
+
241
+ describe('SchemaDiagram', () => {
242
+ afterEach(() => {
243
+ cleanup();
244
+ });
245
+
246
+ beforeEach(() => {
247
+ mockHtml2canvas.mockClear();
248
+ });
249
+
250
+ // ── Rendering ───────────────────────────────────────────────────────────
251
+
252
+ test('renders ReactFlow container', () => {
253
+ const props = createDefaultProps();
254
+ const { container } = render(<SchemaDiagram {...props} />);
255
+
256
+ expect(container.querySelector('[data-testid="mock-react-flow"]')).not.toBeNull();
257
+ });
258
+
259
+ test('shows top-right panel with buttons', () => {
260
+ const props = createDefaultProps();
261
+ const { container } = render(<SchemaDiagram {...props} />);
262
+
263
+ expect(container.querySelector('[data-testid="mock-panel-top-right"]')).not.toBeNull();
264
+ });
265
+
266
+ test('shows top-left info panel', () => {
267
+ const props = createDefaultProps();
268
+ const { container } = render(<SchemaDiagram {...props} />);
269
+
270
+ expect(container.querySelector('[data-testid="mock-panel-top-left"]')).not.toBeNull();
271
+ });
272
+
273
+ test('renders ERD Visualizer heading', () => {
274
+ const props = createDefaultProps();
275
+ const { container } = render(<SchemaDiagram {...props} />);
276
+ const view = within(container);
277
+
278
+ expect(view.queryByText('ERD Visualizer')).not.toBeNull();
279
+ });
280
+
281
+ // ── Close button ────────────────────────────────────────────────────────
282
+
283
+ test('onClose fires when close button clicked', () => {
284
+ const onClose = mock(() => {});
285
+ const props = createDefaultProps({ onClose });
286
+ const { container } = render(<SchemaDiagram {...props} />);
287
+
288
+ const closeButton = Array.from(container.querySelectorAll('button')).find(btn =>
289
+ btn.className.includes('rounded-full')
290
+ );
291
+ expect(closeButton).not.toBeNull();
292
+
293
+ fireEvent.click(closeButton!);
294
+ expect(onClose).toHaveBeenCalledTimes(1);
295
+ });
296
+
297
+ // ── Table count and relationships ───────────────────────────────────────
298
+
299
+ test('shows table info from schema in ERD panel', () => {
300
+ const props = createDefaultProps();
301
+ const { container } = render(<SchemaDiagram {...props} />);
302
+ const view = within(container);
303
+
304
+ expect(view.queryByText('3 tables')).not.toBeNull();
305
+ });
306
+
307
+ test('shows relationship count', () => {
308
+ const props = createDefaultProps();
309
+ const { container } = render(<SchemaDiagram {...props} />);
310
+ const view = within(container);
311
+
312
+ // mockSchema has orders → users FK, so 1 relationship
313
+ expect(view.queryByText('1 relationships')).not.toBeNull();
314
+ });
315
+
316
+ test('shows 0 relationships for schema without FKs', () => {
317
+ const props = createDefaultProps({ schema: schemaNoFK });
318
+ const { container } = render(<SchemaDiagram {...props} />);
319
+ const view = within(container);
320
+
321
+ expect(view.queryByText('0 relationships')).not.toBeNull();
322
+ });
323
+
324
+ test('shows heuristic relationships count for _id columns', () => {
325
+ const props = createDefaultProps({ schema: schemaHeuristic });
326
+ const { container } = render(<SchemaDiagram {...props} />);
327
+ const view = within(container);
328
+
329
+ // comments.user_id → users heuristic edge
330
+ expect(view.queryByText('1 relationships')).not.toBeNull();
331
+ });
332
+
333
+ test('shows single table count', () => {
334
+ const props = createDefaultProps({ schema: singleTableSchema });
335
+ const { container } = render(<SchemaDiagram {...props} />);
336
+ const view = within(container);
337
+
338
+ expect(view.queryByText('1 tables')).not.toBeNull();
339
+ });
340
+
341
+ // ── Export buttons ──────────────────────────────────────────────────────
342
+
343
+ test('export buttons present (PNG, SVG)', () => {
344
+ const props = createDefaultProps();
345
+ const { container } = render(<SchemaDiagram {...props} />);
346
+ const view = within(container);
347
+
348
+ expect(view.queryByText('PNG')).not.toBeNull();
349
+ expect(view.queryByText('SVG')).not.toBeNull();
350
+ });
351
+
352
+ test('PNG export button click does not crash', () => {
353
+ const props = createDefaultProps();
354
+ const { container } = render(<SchemaDiagram {...props} />);
355
+ const view = within(container);
356
+
357
+ const pngButton = view.getByText('PNG').closest('button')!;
358
+ fireEvent.click(pngButton);
359
+ // Should not throw
360
+ });
361
+
362
+ test('SVG export button click does not crash', () => {
363
+ const props = createDefaultProps();
364
+ const { container } = render(<SchemaDiagram {...props} />);
365
+ const view = within(container);
366
+
367
+ const svgButton = view.getByText('SVG').closest('button')!;
368
+ fireEvent.click(svgButton);
369
+ // Should not throw
370
+ });
371
+
372
+ // ── Search input ────────────────────────────────────────────────────────
373
+
374
+ test('search input present with placeholder', () => {
375
+ const props = createDefaultProps();
376
+ const { container } = render(<SchemaDiagram {...props} />);
377
+ const view = within(container);
378
+
379
+ expect(view.queryByPlaceholderText('Filter tables...')).not.toBeNull();
380
+ });
381
+
382
+ test('search filters tables and updates count', () => {
383
+ const props = createDefaultProps();
384
+ const { container } = render(<SchemaDiagram {...props} />);
385
+ const view = within(container);
386
+
387
+ // Initially 3 tables
388
+ expect(view.queryByText('3 tables')).not.toBeNull();
389
+
390
+ const searchInput = view.getByPlaceholderText('Filter tables...');
391
+ fireEvent.change(searchInput, { target: { value: 'users' } });
392
+
393
+ // After filtering, only 1 table matches
394
+ expect(view.queryByText('1 tables')).not.toBeNull();
395
+ expect(view.queryByText('3 tables')).toBeNull();
396
+ });
397
+
398
+ test('search is case-insensitive', () => {
399
+ const props = createDefaultProps();
400
+ const { container } = render(<SchemaDiagram {...props} />);
401
+ const view = within(container);
402
+
403
+ const searchInput = view.getByPlaceholderText('Filter tables...');
404
+ fireEvent.change(searchInput, { target: { value: 'ORDERS' } });
405
+
406
+ expect(view.queryByText('1 tables')).not.toBeNull();
407
+ });
408
+
409
+ test('search with no matches shows 0 tables', () => {
410
+ const props = createDefaultProps();
411
+ const { container } = render(<SchemaDiagram {...props} />);
412
+ const view = within(container);
413
+
414
+ const searchInput = view.getByPlaceholderText('Filter tables...');
415
+ fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
416
+
417
+ expect(view.queryByText('0 tables')).not.toBeNull();
418
+ });
419
+
420
+ test('clearing search restores all tables', () => {
421
+ const props = createDefaultProps();
422
+ const { container } = render(<SchemaDiagram {...props} />);
423
+ const view = within(container);
424
+
425
+ const searchInput = view.getByPlaceholderText('Filter tables...');
426
+
427
+ // Type to filter
428
+ fireEvent.change(searchInput, { target: { value: 'users' } });
429
+ expect(view.queryByText('1 tables')).not.toBeNull();
430
+
431
+ // Clear the search
432
+ fireEvent.change(searchInput, { target: { value: '' } });
433
+ expect(view.queryByText('3 tables')).not.toBeNull();
434
+ });
435
+
436
+ // ── Compact mode toggle ─────────────────────────────────────────────────
437
+
438
+ test('compact mode toggle present showing "Compact" initially', () => {
439
+ const props = createDefaultProps();
440
+ const { container } = render(<SchemaDiagram {...props} />);
441
+ const view = within(container);
442
+
443
+ expect(view.queryByText('Compact')).not.toBeNull();
444
+ expect(view.queryByText('Detail')).toBeNull();
445
+ });
446
+
447
+ test('clicking Compact toggles to Detail', () => {
448
+ const props = createDefaultProps();
449
+ const { container } = render(<SchemaDiagram {...props} />);
450
+ const view = within(container);
451
+
452
+ const compactButton = view.getByText('Compact').closest('button')!;
453
+ fireEvent.click(compactButton);
454
+
455
+ expect(view.queryByText('Detail')).not.toBeNull();
456
+ expect(view.queryByText('Compact')).toBeNull();
457
+ });
458
+
459
+ test('clicking Detail toggles back to Compact', () => {
460
+ const props = createDefaultProps();
461
+ const { container } = render(<SchemaDiagram {...props} />);
462
+ const view = within(container);
463
+
464
+ // Toggle to compact
465
+ const compactButton = view.getByText('Compact').closest('button')!;
466
+ fireEvent.click(compactButton);
467
+ expect(view.queryByText('Detail')).not.toBeNull();
468
+
469
+ // Toggle back to detail
470
+ const detailButton = view.getByText('Detail').closest('button')!;
471
+ fireEvent.click(detailButton);
472
+ expect(view.queryByText('Compact')).not.toBeNull();
473
+ });
474
+
475
+ test('compact button has blue text class when compact mode is active', () => {
476
+ const props = createDefaultProps();
477
+ const { container } = render(<SchemaDiagram {...props} />);
478
+ const view = within(container);
479
+
480
+ const compactButton = view.getByText('Compact').closest('button')!;
481
+ expect(compactButton.className).not.toContain('text-blue-400');
482
+
483
+ fireEvent.click(compactButton);
484
+ const detailButton = view.getByText('Detail').closest('button')!;
485
+ expect(detailButton.className).toContain('text-blue-400');
486
+ });
487
+
488
+ // ── No FK warning ───────────────────────────────────────────────────────
489
+
490
+ test('shows no-FK warning when schema has no foreign keys', () => {
491
+ const props = createDefaultProps({ schema: schemaNoFK });
492
+ const { container } = render(<SchemaDiagram {...props} />);
493
+ const view = within(container);
494
+
495
+ expect(view.queryByText(/No FK data available/)).not.toBeNull();
496
+ expect(view.queryByText(/heuristic relationships/)).not.toBeNull();
497
+ });
498
+
499
+ test('does not show no-FK warning when schema has foreign keys', () => {
500
+ const props = createDefaultProps(); // mockSchema has FK on orders
501
+ const { container } = render(<SchemaDiagram {...props} />);
502
+ const view = within(container);
503
+
504
+ expect(view.queryByText(/No FK data available/)).toBeNull();
505
+ });
506
+
507
+ // ── Selected node info ──────────────────────────────────────────────────
508
+
509
+ test('does not show selected node info by default', () => {
510
+ const props = createDefaultProps();
511
+ const { container } = render(<SchemaDiagram {...props} />);
512
+ const view = within(container);
513
+
514
+ expect(view.queryByText('Selected:')).toBeNull();
515
+ expect(view.queryByText('clear')).toBeNull();
516
+ });
517
+
518
+ // ── Empty schema / loading state ────────────────────────────────────────
519
+
520
+ test('empty schema shows loading/generating state', () => {
521
+ const props = createDefaultProps({ schema: emptySchema });
522
+ const { container } = render(<SchemaDiagram {...props} />);
523
+ const view = within(container);
524
+
525
+ expect(view.queryByText('Generating ERD Diagram...')).not.toBeNull();
526
+ });
527
+
528
+ test('empty schema does not render ReactFlow', () => {
529
+ const props = createDefaultProps({ schema: emptySchema });
530
+ const { container } = render(<SchemaDiagram {...props} />);
531
+
532
+ expect(container.querySelector('[data-testid="mock-react-flow"]')).toBeNull();
533
+ });
534
+
535
+ test('empty schema does not render panels', () => {
536
+ const props = createDefaultProps({ schema: emptySchema });
537
+ const { container } = render(<SchemaDiagram {...props} />);
538
+ const view = within(container);
539
+
540
+ expect(view.queryByText('ERD Visualizer')).toBeNull();
541
+ expect(view.queryByText('PNG')).toBeNull();
542
+ expect(view.queryByText('Compact')).toBeNull();
543
+ expect(view.queryByPlaceholderText('Filter tables...')).toBeNull();
544
+ });
545
+
546
+ // ── Search affects edge count ───────────────────────────────────────────
547
+
548
+ test('filtering to table with FK shows its relationships', () => {
549
+ const props = createDefaultProps();
550
+ const { container } = render(<SchemaDiagram {...props} />);
551
+ const view = within(container);
552
+
553
+ // Search for "orders" — has FK to users, but users is filtered out
554
+ const searchInput = view.getByPlaceholderText('Filter tables...');
555
+ fireEvent.change(searchInput, { target: { value: 'orders' } });
556
+
557
+ // Only orders table visible, users is filtered out → FK edge excluded (target not in set)
558
+ expect(view.queryByText('1 tables')).not.toBeNull();
559
+ expect(view.queryByText('0 relationships')).not.toBeNull();
560
+ });
561
+
562
+ // ── Heuristic edge detection ────────────────────────────────────────────
563
+
564
+ test('heuristic edges are created for _id columns when no FK data', () => {
565
+ const props = createDefaultProps({ schema: schemaHeuristic });
566
+ const { container } = render(<SchemaDiagram {...props} />);
567
+ const view = within(container);
568
+
569
+ // comments has user_id → should heuristically link to users
570
+ expect(view.queryByText('1 relationships')).not.toBeNull();
571
+ });
572
+
573
+ test('heuristic edges not created when real FK data exists', () => {
574
+ // mockSchema has real FK on orders.user_id → users.id
575
+ // so heuristic fallback should NOT run
576
+ const props = createDefaultProps();
577
+ const { container } = render(<SchemaDiagram {...props} />);
578
+ const view = within(container);
579
+
580
+ // Only 1 real FK edge, no extra heuristic
581
+ expect(view.queryByText('1 relationships')).not.toBeNull();
582
+ });
583
+
584
+ // ── Multiple re-renders don't crash ─────────────────────────────────────
585
+
586
+ test('re-rendering with different schema does not crash', () => {
587
+ const onClose = mock(() => {});
588
+ const { container, rerender } = render(
589
+ <SchemaDiagram schema={mockSchema} onClose={onClose} />
590
+ );
591
+ const view = within(container);
592
+ expect(view.queryByText('3 tables')).not.toBeNull();
593
+
594
+ rerender(<SchemaDiagram schema={singleTableSchema} onClose={onClose} />);
595
+ expect(view.queryByText('1 tables')).not.toBeNull();
596
+ });
597
+
598
+ // ── Panel buttons ─────────────────────────────────────────────────────
599
+
600
+ test('top-right panel has PNG, SVG, Compact, and close buttons', () => {
601
+ const props = createDefaultProps();
602
+ const { container } = render(<SchemaDiagram {...props} />);
603
+ const view = within(container);
604
+ expect(view.queryByText('PNG')).not.toBeNull();
605
+ expect(view.queryByText('SVG')).not.toBeNull();
606
+ expect(view.queryByText('Compact')).not.toBeNull();
607
+ // Close button (X icon)
608
+ const closeBtn = Array.from(container.querySelectorAll('button')).find(btn =>
609
+ btn.className.includes('rounded-full')
610
+ );
611
+ expect(closeBtn).not.toBeNull();
612
+ });
613
+
614
+ // ── MiniMap rendered ─────────────────────────────────────────────────
615
+
616
+ test('MiniMap component renders', () => {
617
+ const props = createDefaultProps();
618
+ const { container } = render(<SchemaDiagram {...props} />);
619
+ expect(container.querySelector('[data-testid="mock-minimap"]')).not.toBeNull();
620
+ });
621
+
622
+ // ── Schema with many tables ─────────────────────────────────────────
623
+
624
+ test('schema with many tables renders correct count', () => {
625
+ const manyTables: TableSchema[] = Array.from({ length: 10 }, (_, i) => ({
626
+ name: `table_${i}`,
627
+ columns: [{ name: 'id', type: 'integer', nullable: false, isPrimary: true }],
628
+ indexes: [],
629
+ foreignKeys: [],
630
+ rowCount: i * 10,
631
+ }));
632
+ const props = createDefaultProps({ schema: manyTables });
633
+ const { container } = render(<SchemaDiagram {...props} />);
634
+ const view = within(container);
635
+ expect(view.queryByText('10 tables')).not.toBeNull();
636
+ expect(view.queryByText('0 relationships')).not.toBeNull();
637
+ });
638
+
639
+ // ── Search with partial match ───────────────────────────────────────
640
+
641
+ test('search with partial match filters correctly', () => {
642
+ const props = createDefaultProps();
643
+ const { container } = render(<SchemaDiagram {...props} />);
644
+ const view = within(container);
645
+
646
+ const searchInput = view.getByPlaceholderText('Filter tables...');
647
+ fireEvent.change(searchInput, { target: { value: 'ord' } });
648
+ // 'orders' matches 'ord'
649
+ expect(view.queryByText('1 tables')).not.toBeNull();
650
+ });
651
+
652
+ // ═══════════════════════════════════════════════════════════════════════
653
+ // NEW: TableNode Rendering Tests
654
+ // ═══════════════════════════════════════════════════════════════════════
655
+
656
+ describe('TableNode rendering', () => {
657
+ test('renders table name in header', () => {
658
+ const props = createDefaultProps();
659
+ const { container } = render(<SchemaDiagram {...props} />);
660
+ const view = within(container);
661
+
662
+ // Each table name should appear in uppercase in the header
663
+ expect(view.queryByText('users')).not.toBeNull();
664
+ expect(view.queryByText('orders')).not.toBeNull();
665
+ expect(view.queryByText('products')).not.toBeNull();
666
+ });
667
+
668
+ test('shows column count badge', () => {
669
+ const props = createDefaultProps();
670
+ const { container } = render(<SchemaDiagram {...props} />);
671
+ const view = within(container);
672
+
673
+ // users has 6 columns, orders has 5, products has 4
674
+ expect(view.queryByText('6 cols')).not.toBeNull();
675
+ expect(view.queryByText('5 cols')).not.toBeNull();
676
+ expect(view.queryByText('4 cols')).not.toBeNull();
677
+ });
678
+
679
+ test('displays column names', () => {
680
+ const props = createDefaultProps({ schema: singleTableSchema });
681
+ const { container } = render(<SchemaDiagram {...props} />);
682
+ const view = within(container);
683
+
684
+ expect(view.queryByText('key')).not.toBeNull();
685
+ expect(view.queryByText('value')).not.toBeNull();
686
+ });
687
+
688
+ test('displays column type text', () => {
689
+ const props = createDefaultProps({ schema: singleTableSchema });
690
+ const { container } = render(<SchemaDiagram {...props} />);
691
+
692
+ // Column types should be rendered in uppercase
693
+ const texts = Array.from(container.querySelectorAll('.font-mono'));
694
+ const typeTexts = texts.map(el => el.textContent);
695
+ expect(typeTexts).toContain('text');
696
+ });
697
+
698
+ test('shows NN for NOT NULL columns', () => {
699
+ const props = createDefaultProps({ schema: singleTableSchema });
700
+ const { container } = render(<SchemaDiagram {...props} />);
701
+
702
+ // 'key' column has nullable: false
703
+ const nnElements = container.querySelectorAll('span');
704
+ const nnTexts = Array.from(nnElements).map(el => el.textContent);
705
+ expect(nnTexts).toContain('NN');
706
+ });
707
+
708
+ test('compact mode hides column details', () => {
709
+ const props = createDefaultProps({ schema: singleTableSchema });
710
+ const { container } = render(<SchemaDiagram {...props} />);
711
+ const view = within(container);
712
+
713
+ // Before compact — columns visible
714
+ expect(view.queryByText('key')).not.toBeNull();
715
+ expect(view.queryByText('value')).not.toBeNull();
716
+
717
+ // Toggle compact mode
718
+ const compactButton = view.getByText('Compact').closest('button')!;
719
+ fireEvent.click(compactButton);
720
+
721
+ // In compact mode, columns should be hidden (only header visible)
722
+ // The header still shows settings and "2 cols"
723
+ expect(view.queryByText('settings')).not.toBeNull();
724
+ expect(view.queryByText('2 cols')).not.toBeNull();
725
+ // Column names should not appear as separate elements in the columns list
726
+ // key/value are column names, but the column list section is hidden in compact
727
+ const nodeEl = container.querySelector('[data-node-id="settings"]');
728
+ expect(nodeEl).not.toBeNull();
729
+ // In compact mode, the p-1 div with columns is not rendered
730
+ // We check that column type badges disappear
731
+ const fontMonoElements = nodeEl!.querySelectorAll('.font-mono');
732
+ expect(fontMonoElements.length).toBe(0);
733
+ });
734
+
735
+ test('renders node for each table in schema', () => {
736
+ const props = createDefaultProps();
737
+ const { container } = render(<SchemaDiagram {...props} />);
738
+
739
+ expect(container.querySelector('[data-node-id="users"]')).not.toBeNull();
740
+ expect(container.querySelector('[data-node-id="orders"]')).not.toBeNull();
741
+ expect(container.querySelector('[data-node-id="products"]')).not.toBeNull();
742
+ });
743
+
744
+ test('node with empty/null data returns nothing', () => {
745
+ // Schema with a valid table ensures at least one node renders
746
+ // The guard `if (!data) return null; if (!table) return null;` is tested
747
+ // by the fact that the enhanced mock passes correct data through
748
+ const props = createDefaultProps({ schema: singleTableSchema });
749
+ const { container } = render(<SchemaDiagram {...props} />);
750
+
751
+ const nodeEl = container.querySelector('[data-node-id="settings"]');
752
+ expect(nodeEl).not.toBeNull();
753
+ // The node should have content (table header)
754
+ expect(nodeEl!.textContent).toContain('settings');
755
+ });
756
+ });
757
+
758
+ // ═══════════════════════════════════════════════════════════════════════
759
+ // NEW: Node Selection Tests
760
+ // ═══════════════════════════════════════════════════════════════════════
761
+
762
+ describe('Node selection', () => {
763
+ test('clicking a node shows "Selected:" info panel', () => {
764
+ const props = createDefaultProps();
765
+ const { container } = render(<SchemaDiagram {...props} />);
766
+ const view = within(container);
767
+
768
+ // Initially no selection
769
+ expect(view.queryByText('Selected:')).toBeNull();
770
+
771
+ // Click the users node
772
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
773
+ fireEvent.click(usersNode);
774
+
775
+ // Selection should appear with selected node name and clear button
776
+ expect(view.queryByText('Selected:')).not.toBeNull();
777
+ // The selected table name appears in a font-mono span
778
+ const selectedSpan = container.querySelector('.font-mono.font-bold');
779
+ expect(selectedSpan).not.toBeNull();
780
+ expect(selectedSpan!.textContent).toBe('users');
781
+ expect(view.queryByText('clear')).not.toBeNull();
782
+ });
783
+
784
+ test('clicking the same node again deselects (toggle)', () => {
785
+ const props = createDefaultProps();
786
+ const { container } = render(<SchemaDiagram {...props} />);
787
+ const view = within(container);
788
+
789
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
790
+
791
+ // Select
792
+ fireEvent.click(usersNode);
793
+ expect(view.queryByText('Selected:')).not.toBeNull();
794
+
795
+ // Deselect
796
+ fireEvent.click(usersNode);
797
+ expect(view.queryByText('Selected:')).toBeNull();
798
+ });
799
+
800
+ test('clicking "clear" button clears selection', () => {
801
+ const props = createDefaultProps();
802
+ const { container } = render(<SchemaDiagram {...props} />);
803
+ const view = within(container);
804
+
805
+ // Select a node
806
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
807
+ fireEvent.click(usersNode);
808
+ expect(view.queryByText('Selected:')).not.toBeNull();
809
+
810
+ // Click clear
811
+ const clearButton = view.getByText('clear');
812
+ fireEvent.click(clearButton);
813
+ expect(view.queryByText('Selected:')).toBeNull();
814
+ });
815
+
816
+ test('clicking pane background clears selection', () => {
817
+ const props = createDefaultProps();
818
+ const { container } = render(<SchemaDiagram {...props} />);
819
+ const view = within(container);
820
+
821
+ // Select a node
822
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
823
+ fireEvent.click(usersNode);
824
+ expect(view.queryByText('Selected:')).not.toBeNull();
825
+
826
+ // Click the pane background (the react-flow container itself)
827
+ const reactFlowContainer = container.querySelector('[data-testid="mock-react-flow"]')!;
828
+ // Fire click directly on the container element (target === currentTarget)
829
+ fireEvent.click(reactFlowContainer);
830
+ expect(view.queryByText('Selected:')).toBeNull();
831
+ });
832
+ });
833
+
834
+ // ═══════════════════════════════════════════════════════════════════════
835
+ // NEW: Node/Edge Highlighting Tests
836
+ // ═══════════════════════════════════════════════════════════════════════
837
+
838
+ describe('Node/Edge highlighting', () => {
839
+ test('selected node gets highlighted (blue border)', () => {
840
+ const props = createDefaultProps();
841
+ const { container } = render(<SchemaDiagram {...props} />);
842
+
843
+ // Click users node
844
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
845
+ fireEvent.click(usersNode);
846
+
847
+ // The TableNode's root div inside the data-node-id div should have blue border
848
+ const innerDiv = usersNode.querySelector('.border-blue-500\\/60');
849
+ expect(innerDiv).not.toBeNull();
850
+ });
851
+
852
+ test('FK target of selected node is highlighted', () => {
853
+ const props = createDefaultProps();
854
+ const { container } = render(<SchemaDiagram {...props} />);
855
+
856
+ // Select 'orders' which has FK to 'users'
857
+ const ordersNode = container.querySelector('[data-node-id="orders"]')!;
858
+ fireEvent.click(ordersNode);
859
+
860
+ // The 'users' table should also be highlighted (FK target)
861
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
862
+ const usersInner = usersNode.querySelector('.border-blue-500\\/60');
863
+ expect(usersInner).not.toBeNull();
864
+ });
865
+
866
+ test('FK source of selected node is highlighted', () => {
867
+ const props = createDefaultProps();
868
+ const { container } = render(<SchemaDiagram {...props} />);
869
+
870
+ // Select 'users' — orders has FK pointing to users
871
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
872
+ fireEvent.click(usersNode);
873
+
874
+ // The 'orders' table should be highlighted (it references users via FK)
875
+ const ordersNode = container.querySelector('[data-node-id="orders"]')!;
876
+ const ordersInner = ordersNode.querySelector('.border-blue-500\\/60');
877
+ expect(ordersInner).not.toBeNull();
878
+ });
879
+
880
+ test('non-related node is NOT highlighted when another is selected', () => {
881
+ const props = createDefaultProps();
882
+ const { container } = render(<SchemaDiagram {...props} />);
883
+
884
+ // Select 'orders' (related to users via FK, not related to products)
885
+ const ordersNode = container.querySelector('[data-node-id="orders"]')!;
886
+ fireEvent.click(ordersNode);
887
+
888
+ // Products should NOT be highlighted
889
+ const productsNode = container.querySelector('[data-node-id="products"]')!;
890
+ const productsInner = productsNode.querySelector('.border-blue-500\\/60');
891
+ expect(productsInner).toBeNull();
892
+ // Products should have default border
893
+ const productsDefault = productsNode.querySelector('.border-white\\/10');
894
+ expect(productsDefault).not.toBeNull();
895
+ });
896
+ });
897
+
898
+ // ═══════════════════════════════════════════════════════════════════════
899
+ // NEW: Export Internals Tests
900
+ // ═══════════════════════════════════════════════════════════════════════
901
+
902
+ describe('Export functionality', () => {
903
+ test('PNG export calls html2canvas and creates download link', async () => {
904
+ const clickMock = mock(() => {});
905
+ const originalCreateElement = document.createElement.bind(document);
906
+ const createElementSpy = mock((tag: string) => {
907
+ const el = originalCreateElement(tag);
908
+ if (tag === 'a') {
909
+ Object.defineProperty(el, 'click', { value: clickMock });
910
+ }
911
+ return el;
912
+ });
913
+ document.createElement = createElementSpy as unknown as typeof document.createElement;
914
+
915
+ const props = createDefaultProps();
916
+ const { container } = render(<SchemaDiagram {...props} />);
917
+ const view = within(container);
918
+
919
+ const pngButton = view.getByText('PNG').closest('button')!;
920
+ await act(async () => {
921
+ fireEvent.click(pngButton);
922
+ });
923
+
924
+ // html2canvas should have been called
925
+ expect(mockHtml2canvas).toHaveBeenCalledTimes(1);
926
+
927
+ // Wait for the async chain
928
+ await act(async () => {
929
+ await new Promise(r => setTimeout(r, 10));
930
+ });
931
+
932
+ expect(clickMock).toHaveBeenCalled();
933
+
934
+ // Restore
935
+ document.createElement = originalCreateElement;
936
+ });
937
+
938
+ test('SVG export uses XMLSerializer and creates download link', async () => {
939
+ const clickMock = mock(() => {});
940
+ const revokeObjectURLMock = mock(() => {});
941
+ const originalCreateElement = document.createElement.bind(document);
942
+ const createElementSpy = mock((tag: string) => {
943
+ const el = originalCreateElement(tag);
944
+ if (tag === 'a') {
945
+ Object.defineProperty(el, 'click', { value: clickMock });
946
+ }
947
+ return el;
948
+ });
949
+ document.createElement = createElementSpy as unknown as typeof document.createElement;
950
+
951
+ const originalRevokeObjectURL = URL.revokeObjectURL;
952
+ URL.revokeObjectURL = revokeObjectURLMock;
953
+
954
+ const props = createDefaultProps();
955
+ const { container } = render(<SchemaDiagram {...props} />);
956
+ const view = within(container);
957
+
958
+ const svgButton = view.getByText('SVG').closest('button')!;
959
+ await act(async () => {
960
+ fireEvent.click(svgButton);
961
+ });
962
+
963
+ // Wait for async chain
964
+ await act(async () => {
965
+ await new Promise(r => setTimeout(r, 10));
966
+ });
967
+
968
+ expect(clickMock).toHaveBeenCalled();
969
+ expect(revokeObjectURLMock).toHaveBeenCalled();
970
+
971
+ // Restore
972
+ document.createElement = originalCreateElement;
973
+ URL.revokeObjectURL = originalRevokeObjectURL;
974
+ });
975
+ });
976
+
977
+ // ═══════════════════════════════════════════════════════════════════════
978
+ // NEW: Edge Construction & Misc Tests
979
+ // ═══════════════════════════════════════════════════════════════════════
980
+
981
+ describe('Edge construction and misc', () => {
982
+ test('heuristic matches singular table name (author_id → author)', () => {
983
+ const props = createDefaultProps({ schema: schemaHeuristicSingular });
984
+ const { container } = render(<SchemaDiagram {...props} />);
985
+ const view = within(container);
986
+
987
+ // books.author_id → author (singular match, not authors)
988
+ expect(view.queryByText('1 relationships')).not.toBeNull();
989
+ });
990
+
991
+ test('schema with undefined foreignKeys does not crash', () => {
992
+ const props = createDefaultProps({ schema: schemaUndefinedFK });
993
+ const { container } = render(<SchemaDiagram {...props} />);
994
+ const view = within(container);
995
+
996
+ expect(view.queryByText('1 tables')).not.toBeNull();
997
+ expect(view.queryByText('0 relationships')).not.toBeNull();
998
+ });
999
+
1000
+ test('multi-FK schema shows correct relationship count', () => {
1001
+ const props = createDefaultProps({ schema: schemaMultiFK });
1002
+ const { container } = render(<SchemaDiagram {...props} />);
1003
+ const view = within(container);
1004
+
1005
+ // orders→users + items→orders = 2 relationships
1006
+ expect(view.queryByText('2 relationships')).not.toBeNull();
1007
+ });
1008
+
1009
+ test('multi-FK: selecting middle node highlights both connected nodes', () => {
1010
+ const props = createDefaultProps({ schema: schemaMultiFK });
1011
+ const { container } = render(<SchemaDiagram {...props} />);
1012
+
1013
+ // Select 'orders' which is FK target of 'items' and FK source pointing to 'users'
1014
+ const ordersNode = container.querySelector('[data-node-id="orders"]')!;
1015
+ fireEvent.click(ordersNode);
1016
+
1017
+ // 'users' should be highlighted (orders has FK to users)
1018
+ const usersNode = container.querySelector('[data-node-id="users"]')!;
1019
+ expect(usersNode.querySelector('.border-blue-500\\/60')).not.toBeNull();
1020
+
1021
+ // 'items' should be highlighted (items has FK to orders)
1022
+ const itemsNode = container.querySelector('[data-node-id="items"]')!;
1023
+ expect(itemsNode.querySelector('.border-blue-500\\/60')).not.toBeNull();
1024
+ });
1025
+
1026
+ test('no-FK warning shown for schema with undefined foreignKeys', () => {
1027
+ const props = createDefaultProps({ schema: schemaUndefinedFK });
1028
+ const { container } = render(<SchemaDiagram {...props} />);
1029
+ const view = within(container);
1030
+
1031
+ expect(view.queryByText(/No FK data available/)).not.toBeNull();
1032
+ });
1033
+ });
1034
+ });