SidePanelEditor.utils.createTable.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. import { FOREIGN_KEY_CASCADE_ACTION, safeSql } from '@supabase/pg-meta'
  2. import { beforeEach, describe, expect, it, vi } from 'vitest'
  3. import type { ForeignKey } from './ForeignKeySelector/ForeignKeySelector.types'
  4. import type { ColumnField } from './SidePanelEditor.types'
  5. import { createTable } from './SidePanelEditor.utils'
  6. // Define mock functions at module level
  7. const mockExecuteSql = vi.fn()
  8. const mockGetTable = vi.fn()
  9. const mockSendEvent = vi.fn()
  10. const mockPrefetchEditorTablePage = vi.fn()
  11. const mockToastLoading = vi.fn()
  12. const mockToastSuccess = vi.fn()
  13. const mockToastError = vi.fn()
  14. const mockFetchQuery = vi.fn()
  15. // Setup mocks before imports
  16. vi.mock('@/data/query-client', () => ({
  17. getQueryClient: () => ({
  18. fetchQuery: mockFetchQuery,
  19. }),
  20. }))
  21. vi.mock('@/data/sql/execute-sql-query', () => ({
  22. executeSql: (...args: unknown[]) => mockExecuteSql(...args),
  23. }))
  24. vi.mock('@/data/tables/table-retrieve-query', () => ({
  25. getTable: (...args: unknown[]) => mockGetTable(...args),
  26. getTableQuery: (...args: unknown[]) => mockGetTable(...args),
  27. }))
  28. vi.mock('@/data/telemetry/send-event-mutation', () => ({
  29. sendEvent: (...args: unknown[]) => mockSendEvent(...args),
  30. }))
  31. vi.mock('@/data/prefetchers/project.$ref.editor.$id', () => ({
  32. prefetchEditorTablePage: (...args: unknown[]) => mockPrefetchEditorTablePage(...args),
  33. }))
  34. vi.mock('sonner', () => ({
  35. toast: {
  36. loading: (...args: unknown[]) => mockToastLoading(...args),
  37. success: (...args: unknown[]) => mockToastSuccess(...args),
  38. error: (...args: unknown[]) => mockToastError(...args),
  39. },
  40. }))
  41. // Mock SparkBar component used in toast
  42. vi.mock('@/components/ui/SparkBar', () => ({
  43. default: () => null,
  44. }))
  45. // Helper to create a column field with defaults
  46. const createColumnField = (overrides: Partial<ColumnField> = {}): ColumnField => ({
  47. id: 'col-1',
  48. name: 'column',
  49. table: 'test_table',
  50. schema: 'public',
  51. format: 'text',
  52. check: null,
  53. comment: null,
  54. defaultValue: null,
  55. isNullable: true,
  56. isUnique: false,
  57. isArray: false,
  58. isIdentity: false,
  59. isPrimaryKey: false,
  60. isNewColumn: true,
  61. isEncrypted: false,
  62. ...overrides,
  63. })
  64. describe('createTable', () => {
  65. const projectRef = 'test-project-ref'
  66. const connectionString = 'postgresql://localhost:5432/test'
  67. const toastId = 'test-toast-id'
  68. const basePayload = {
  69. name: 'test_table',
  70. schema: 'public',
  71. comment: 'A test table',
  72. }
  73. const mockTableResult = {
  74. id: 123,
  75. name: 'test_table',
  76. schema: 'public',
  77. comment: 'A test table',
  78. columns: [],
  79. primary_keys: [],
  80. relationships: [],
  81. }
  82. beforeEach(() => {
  83. vi.clearAllMocks()
  84. // Default mock implementations
  85. mockExecuteSql.mockResolvedValue({ result: [] })
  86. mockGetTable.mockResolvedValue(mockTableResult)
  87. mockSendEvent.mockResolvedValue({})
  88. mockPrefetchEditorTablePage.mockResolvedValue(undefined)
  89. mockFetchQuery.mockImplementation(({ queryFn }) => {
  90. if (queryFn) {
  91. return queryFn({ signal: new AbortController().signal })
  92. }
  93. return Promise.resolve(mockTableResult)
  94. })
  95. })
  96. it('should create a basic table with no columns', async () => {
  97. const result = await createTable({
  98. projectRef,
  99. connectionString,
  100. toastId,
  101. payload: basePayload,
  102. columns: [],
  103. foreignKeyRelations: [],
  104. isRLSEnabled: false,
  105. })
  106. expect(mockExecuteSql).toHaveBeenCalledTimes(1)
  107. expect(mockExecuteSql).toHaveBeenCalledWith(
  108. expect.objectContaining({
  109. projectRef,
  110. connectionString,
  111. queryKey: ['table', 'create-with-columns'],
  112. })
  113. )
  114. // Should show loading toast
  115. expect(mockToastLoading).toHaveBeenCalledWith(`Creating table ${basePayload.name}...`, {
  116. id: toastId,
  117. })
  118. // Should track table creation event
  119. expect(mockSendEvent).toHaveBeenCalledWith({
  120. event: {
  121. action: 'table_created',
  122. properties: {
  123. has_generated_policies: false,
  124. method: 'table_editor',
  125. schema_name: 'public',
  126. table_name: 'test_table',
  127. },
  128. groups: {
  129. project: projectRef,
  130. },
  131. },
  132. })
  133. // Should prefetch the editor table page
  134. expect(mockPrefetchEditorTablePage).toHaveBeenCalledWith(
  135. expect.objectContaining({
  136. projectRef,
  137. connectionString,
  138. id: mockTableResult.id,
  139. })
  140. )
  141. expect(result).toStrictEqual({
  142. failedPolicies: [],
  143. table: mockTableResult,
  144. })
  145. })
  146. it('should create a table with RLS enabled', async () => {
  147. await createTable({
  148. projectRef,
  149. connectionString,
  150. toastId,
  151. payload: basePayload,
  152. columns: [],
  153. foreignKeyRelations: [],
  154. isRLSEnabled: true,
  155. })
  156. const sqlCall = mockExecuteSql.mock.calls[0][0]
  157. expect(sqlCall.sql).toContain('ENABLE ROW LEVEL SECURITY')
  158. expect(mockSendEvent).toHaveBeenCalledWith({
  159. event: {
  160. action: 'table_rls_enabled',
  161. properties: {
  162. method: 'table_editor',
  163. schema_name: 'public',
  164. table_name: 'test_table',
  165. },
  166. groups: {
  167. project: projectRef,
  168. },
  169. },
  170. })
  171. })
  172. it('should create a table with columns', async () => {
  173. const columns: ColumnField[] = [
  174. createColumnField({
  175. id: 'col-1',
  176. name: 'id',
  177. format: 'int8',
  178. isNullable: false,
  179. isIdentity: true,
  180. isPrimaryKey: true,
  181. }),
  182. createColumnField({
  183. id: 'col-2',
  184. name: 'name',
  185. format: 'text',
  186. comment: 'User name',
  187. }),
  188. ]
  189. await createTable({
  190. projectRef,
  191. connectionString,
  192. toastId,
  193. payload: basePayload,
  194. columns,
  195. foreignKeyRelations: [],
  196. isRLSEnabled: false,
  197. })
  198. const sqlCall = mockExecuteSql.mock.calls[0][0]
  199. expect(sqlCall.sql).toContain('ALTER TABLE')
  200. expect(sqlCall.sql).toContain('ADD COLUMN')
  201. expect(sqlCall.sql).toContain('ADD PRIMARY KEY')
  202. expect(sqlCall.sql).toContain('id')
  203. })
  204. it('should create a table with composite primary key', async () => {
  205. const columns: ColumnField[] = [
  206. createColumnField({
  207. id: 'col-1',
  208. name: 'user_id',
  209. format: 'int8',
  210. isNullable: false,
  211. isPrimaryKey: true,
  212. }),
  213. createColumnField({
  214. id: 'col-2',
  215. name: 'order_id',
  216. format: 'int8',
  217. isNullable: false,
  218. isPrimaryKey: true,
  219. }),
  220. ]
  221. await createTable({
  222. projectRef,
  223. connectionString,
  224. toastId,
  225. payload: basePayload,
  226. columns,
  227. foreignKeyRelations: [],
  228. isRLSEnabled: false,
  229. })
  230. const sqlCall = mockExecuteSql.mock.calls[0][0]
  231. expect(sqlCall.sql).toContain('ADD PRIMARY KEY')
  232. expect(sqlCall.sql).toContain('user_id')
  233. expect(sqlCall.sql).toContain('order_id')
  234. })
  235. it('should create a table with foreign key relations', async () => {
  236. const columns: ColumnField[] = [
  237. createColumnField({
  238. id: 'col-1',
  239. name: 'id',
  240. format: 'int8',
  241. isNullable: false,
  242. isIdentity: true,
  243. isPrimaryKey: true,
  244. }),
  245. createColumnField({
  246. id: 'col-2',
  247. name: 'user_id',
  248. format: 'int8',
  249. isNullable: false,
  250. }),
  251. ]
  252. const foreignKeyRelations: ForeignKey[] = [
  253. {
  254. schema: 'public',
  255. table: 'users',
  256. columns: [{ source: 'user_id', target: 'id' }],
  257. deletionAction: FOREIGN_KEY_CASCADE_ACTION.CASCADE,
  258. updateAction: FOREIGN_KEY_CASCADE_ACTION.NO_ACTION,
  259. },
  260. ]
  261. await createTable({
  262. projectRef,
  263. connectionString,
  264. toastId,
  265. payload: basePayload,
  266. columns,
  267. foreignKeyRelations,
  268. isRLSEnabled: false,
  269. })
  270. const sqlCall = mockExecuteSql.mock.calls[0][0]
  271. expect(sqlCall.sql).toContain('ADD FOREIGN KEY')
  272. expect(sqlCall.sql).toContain('REFERENCES')
  273. expect(sqlCall.sql).toContain('users')
  274. expect(sqlCall.sql).toContain('ON DELETE CASCADE')
  275. })
  276. it('should include organization slug in telemetry when provided', async () => {
  277. const organizationSlug = 'test-org'
  278. await createTable({
  279. projectRef,
  280. connectionString,
  281. toastId,
  282. payload: basePayload,
  283. columns: [],
  284. foreignKeyRelations: [],
  285. isRLSEnabled: true,
  286. organizationSlug,
  287. })
  288. expect(mockSendEvent).toHaveBeenCalledWith({
  289. event: expect.objectContaining({
  290. action: 'table_created',
  291. groups: {
  292. project: projectRef,
  293. organization: organizationSlug,
  294. },
  295. }),
  296. })
  297. expect(mockSendEvent).toHaveBeenCalledWith({
  298. event: expect.objectContaining({
  299. action: 'table_rls_enabled',
  300. groups: {
  301. project: projectRef,
  302. organization: organizationSlug,
  303. },
  304. }),
  305. })
  306. })
  307. it('should handle telemetry errors gracefully', async () => {
  308. const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
  309. mockSendEvent.mockRejectedValue(new Error('Telemetry failed'))
  310. const result = await createTable({
  311. projectRef,
  312. connectionString,
  313. toastId,
  314. payload: basePayload,
  315. columns: [],
  316. foreignKeyRelations: [],
  317. isRLSEnabled: false,
  318. })
  319. expect(result).toStrictEqual({
  320. failedPolicies: [],
  321. table: mockTableResult,
  322. })
  323. expect(consoleErrorSpy).toHaveBeenCalledWith(
  324. 'Failed to track table creation event:',
  325. expect.any(Error)
  326. )
  327. consoleErrorSpy.mockRestore()
  328. })
  329. it('should create a table with nullable connectionString', async () => {
  330. await createTable({
  331. projectRef,
  332. connectionString: null,
  333. toastId,
  334. payload: basePayload,
  335. columns: [],
  336. foreignKeyRelations: [],
  337. isRLSEnabled: false,
  338. })
  339. expect(mockExecuteSql).toHaveBeenCalledWith(
  340. expect.objectContaining({
  341. projectRef,
  342. connectionString: null,
  343. })
  344. )
  345. })
  346. it('should create table with column having default value', async () => {
  347. const columns: ColumnField[] = [
  348. createColumnField({
  349. name: 'status',
  350. defaultValue: "'pending'",
  351. isNullable: false,
  352. }),
  353. ]
  354. await createTable({
  355. projectRef,
  356. connectionString,
  357. toastId,
  358. payload: basePayload,
  359. columns,
  360. foreignKeyRelations: [],
  361. isRLSEnabled: false,
  362. })
  363. expect(mockExecuteSql).toHaveBeenCalledTimes(1)
  364. })
  365. it('should create table with unique column', async () => {
  366. const columns: ColumnField[] = [
  367. createColumnField({
  368. name: 'email',
  369. isNullable: false,
  370. isUnique: true,
  371. }),
  372. ]
  373. await createTable({
  374. projectRef,
  375. connectionString,
  376. toastId,
  377. payload: basePayload,
  378. columns,
  379. foreignKeyRelations: [],
  380. isRLSEnabled: false,
  381. })
  382. expect(mockExecuteSql).toHaveBeenCalledTimes(1)
  383. })
  384. it('should create table with array column', async () => {
  385. const columns: ColumnField[] = [
  386. createColumnField({
  387. name: 'tags',
  388. isArray: true,
  389. }),
  390. ]
  391. await createTable({
  392. projectRef,
  393. connectionString,
  394. toastId,
  395. payload: basePayload,
  396. columns,
  397. foreignKeyRelations: [],
  398. isRLSEnabled: false,
  399. })
  400. expect(mockExecuteSql).toHaveBeenCalledTimes(1)
  401. })
  402. it('should create table with check constraint', async () => {
  403. const columns: ColumnField[] = [
  404. createColumnField({
  405. name: 'age',
  406. format: 'int4',
  407. check: safeSql`age >= 0`,
  408. isNullable: false,
  409. }),
  410. ]
  411. await createTable({
  412. projectRef,
  413. connectionString,
  414. toastId,
  415. payload: basePayload,
  416. columns,
  417. foreignKeyRelations: [],
  418. isRLSEnabled: false,
  419. })
  420. expect(mockExecuteSql).toHaveBeenCalledTimes(1)
  421. })
  422. it('should propagate SQL execution errors', async () => {
  423. mockExecuteSql.mockRejectedValue(new Error('SQL execution failed'))
  424. await expect(
  425. createTable({
  426. projectRef,
  427. connectionString,
  428. toastId,
  429. payload: basePayload,
  430. columns: [],
  431. foreignKeyRelations: [],
  432. isRLSEnabled: false,
  433. })
  434. ).rejects.toThrow('SQL execution failed')
  435. })
  436. it('should create table in non-public schema', async () => {
  437. const customSchemaPayload = {
  438. name: 'custom_table',
  439. schema: 'private',
  440. comment: 'A private table',
  441. }
  442. await createTable({
  443. projectRef,
  444. connectionString,
  445. toastId,
  446. payload: customSchemaPayload,
  447. columns: [],
  448. foreignKeyRelations: [],
  449. isRLSEnabled: false,
  450. })
  451. const sqlCall = mockExecuteSql.mock.calls[0][0]
  452. expect(sqlCall.sql).toMatch(/private\.custom_table|"private"\."custom_table"/)
  453. expect(mockSendEvent).toHaveBeenCalledWith({
  454. event: expect.objectContaining({
  455. properties: expect.objectContaining({
  456. schema_name: 'private',
  457. }),
  458. }),
  459. })
  460. })
  461. })